// Package decisionpep authenticates the producer's decision record for one // current request. It never evaluates policy, caches a verdict, or discovers trust. package decisionpep import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" "github.com/netkingdom/access-engine/internal/sign" "github.com/netkingdom/access-engine/pkg/api" ) var ErrRefused = errors.New("authenticated decision refused") const nonceKey = "decisionpep_request_nonce" const JSONProfile = "lossless-json-v1" const provenanceMode = "standalone/lossless-json-v1" const maxBytes = 2 << 20 // Admission is public, independently admitted owner configuration. TokenFile is // only a path to a private rotating caller credential, never a literal token. type Admission struct { Endpoint string `json:"endpoint"` CAFile string `json:"ca_file"` TokenFile string `json:"token_file"` Keys map[string]string `json:"keys"` Evaluator string `json:"evaluator"` PolicyPackage string `json:"policy_package"` PolicyVersion string `json:"policy_version"` PolicyDigest string `json:"policy_digest"` RegistryDigest string `json:"registry_digest"` CallerPrincipal string `json:"caller_principal"` CallerAudience string `json:"caller_audience"` MaxTTL string `json:"max_ttl"` MaxClockSkew string `json:"max_clock_skew"` } type Client struct { admission Admission keys map[string]ed25519.PublicKey http *http.Client maxTTL, skew time.Duration now func() time.Time } // Load reads a current independently admitted file. No trust is bootstrapped // from a PDP response, token contents, discovery endpoint, or environment value. func Load(path string) (*Client, error) { raw, err := readFile(path, false) if err != nil { return nil, ErrRefused } var a Admission d := json.NewDecoder(bytes.NewReader(raw)) d.DisallowUnknownFields() if d.Decode(&a) != nil { return nil, ErrRefused } if _, err = d.Token(); err != io.EOF { return nil, ErrRefused } return New(a) } func New(a Admission) (*Client, error) { u, err := url.Parse(a.Endpoint) if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "/v1/check" || u.RawQuery != "" || u.Fragment != "" { return nil, ErrRefused } if !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.TokenFile) || a.Evaluator == "" || a.PolicyPackage == "" || a.PolicyVersion == "" || a.CallerPrincipal == "" || a.CallerAudience == "" { return nil, ErrRefused } if !validDigest(a.PolicyDigest) || !validDigest(a.RegistryDigest) || len(a.Keys) == 0 { return nil, ErrRefused } ttl, err := time.ParseDuration(a.MaxTTL) if err != nil || ttl <= 0 { return nil, ErrRefused } skew, err := time.ParseDuration(a.MaxClockSkew) if err != nil || skew < 0 || skew > ttl { return nil, ErrRefused } ca, err := readFile(a.CAFile, false) if err != nil { return nil, ErrRefused } roots := x509.NewCertPool() if !roots.AppendCertsFromPEM(ca) { return nil, ErrRefused } keys := map[string]ed25519.PublicKey{} for kid, value := range a.Keys { b, e := base64.RawURLEncoding.Strict().DecodeString(value) if e != nil || kid == "" || len(b) != ed25519.PublicKeySize { return nil, ErrRefused } keys[kid] = append(ed25519.PublicKey(nil), b...) } transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true} client := &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrRefused }} return &Client{admission: a, keys: keys, http: client, maxTTL: ttl, skew: skew, now: time.Now}, nil } func validDigest(value string) bool { if !strings.HasPrefix(value, "sha256:") || len(value) != 71 { return false } _, e := hex.DecodeString(value[7:]) return e == nil && strings.ToLower(value) == value } func readFile(path string, private bool) ([]byte, error) { if !filepath.IsAbs(path) { return nil, ErrRefused } f, e := os.Open(path) if e != nil { return nil, ErrRefused } defer f.Close() info, e := f.Stat() if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 { return nil, ErrRefused } if private && info.Mode().Perm()&0007 != 0 { return nil, ErrRefused } b, e := io.ReadAll(io.LimitReader(f, maxBytes+1)) if e != nil || len(b) > maxBytes { return nil, ErrRefused } return b, nil } // Check sends exactly one request with a cryptographically fresh nonce. The // returned envelope is the producer's authenticated typed interpretation. // Callers must execute only the request's exact operation and honor their own // execution boundary; a login decision is never a later resource decision. func (c *Client) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { refuse := func() (api.DecisionEnvelope, error) { return api.DecisionEnvelope{}, ErrRefused } if c == nil || request.Purpose != "" || request.Tenant == "" || request.Subject.ID == "" || request.Subject.Type == "" || request.Resource.ID == "" || request.Resource.Type == "" || request.Resource.System == "" || request.Action == "" { return refuse() } if request.Subject.Tenant != request.Tenant || request.Resource.Tenant != request.Tenant { return refuse() } if request.PolicyVersion != "" && request.PolicyVersion != c.admission.PolicyVersion { return refuse() } // Use the producer's typed representation and preserve numeric lexemes. A // producer that rounds before hashing fails this join rather than weakening it. raw, err := json.Marshal(request) if err != nil { return refuse() } decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if decoder.Decode(&request) != nil { return refuse() } if request.Context == nil { request.Context = map[string]any{} } if _, exists := request.Context[nonceKey]; exists { return refuse() } nonce := make([]byte, 32) if _, err = rand.Read(nonce); err != nil { return refuse() } request.Context[nonceKey] = hex.EncodeToString(nonce) request.Context["decisionpep_json_profile"] = JSONProfile request.ID = hex.EncodeToString(nonce) request.PolicyVersion = c.admission.PolicyVersion raw, err = json.Marshal(request) if err != nil || len(raw) > maxBytes { return refuse() } token, err := readFile(c.admission.TokenFile, true) if err != nil { return refuse() } value := strings.TrimSpace(string(token)) if value == "" || strings.ContainsAny(value, " \t\r\n") { return refuse() } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Endpoint, bytes.NewReader(raw)) if err != nil { return refuse() } req.Header.Set("Content-Type", "application/json") req.Header.Set("Access-Engine-JSON-Profile", JSONProfile) req.Header.Set("Authorization", "Bearer "+value) started := c.now().UTC() response, err := c.http.Do(req) if err != nil { return refuse() } defer response.Body.Close() if response.StatusCode != http.StatusOK { return refuse() } body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1)) if err != nil || len(body) > maxBytes { return refuse() } envelope, err := sign.DecodeEnvelope(body) if err != nil || sign.Verify(envelope, c.keys) != nil { return refuse() } if c.validate(envelope, request, started, c.now().UTC()) != nil { return refuse() } return envelope, nil } func (c *Client) validate(e api.DecisionEnvelope, r api.CheckRequest, started, now time.Time) error { p := e.Provenance b := e.Binding l := e.Lifetime if e.ID == "" || e.ContractVersion != api.DecisionRecordContractV1 || e.Effect != api.DecisionEffectAllow || e.RequestID != r.ID || len(e.Obligations) != 0 || b == nil || l == nil { return ErrRefused } if p.Evaluator != c.admission.Evaluator || p.Mode != provenanceMode || p.PolicyPackage != c.admission.PolicyPackage || p.PolicyVersion != c.admission.PolicyVersion || p.PolicyPackageDigest != c.admission.PolicyDigest || p.RegistrySnapshotDigest != c.admission.RegistryDigest || e.MatchedPolicyVersion != p.PolicyVersion { return ErrRefused } if b.Purpose != "" || b.SubmittedRequestDigest != api.RequestDigest(r) || b.Tenant != r.Tenant || b.Action != r.Action || api.CanonicalDigest(b.Context) != api.CanonicalDigest(r.Context) { return ErrRefused } if !sameSubject(b.Subject, r.Subject) || !sameResource(b.Resource, r.Resource) || !sameSubject(e.Subject, b.Subject) || !sameResource(e.Resource, b.Resource) { return ErrRefused } evaluated := api.CheckRequest{Tenant: b.Tenant, Subject: b.Subject, Action: b.Action, Resource: b.Resource, Context: b.Context, Purpose: b.Purpose} if b.RequestDigest != api.RequestDigest(evaluated) { return ErrRefused } for k, v := range api.InputClaimDigests(r) { if p.InputClaimDigests[k] != v { return ErrRefused } } caller := p.Caller if caller == nil || caller.Mode != "enforce" || caller.Principal != c.admission.CallerPrincipal || caller.Audience != c.admission.CallerAudience { return ErrRefused } callerEnd, err := time.Parse(time.RFC3339Nano, caller.NotAfter) if err != nil || !now.Before(callerEnd) { return ErrRefused } decision, err := time.Parse(time.RFC3339Nano, p.DecisionTime) if err != nil || decision.Before(started.Add(-c.skew)) || decision.After(now.Add(c.skew)) { return ErrRefused } begin, err := time.Parse(time.RFC3339Nano, l.NotBefore) if err != nil || begin.After(now) { return ErrRefused } end, err := time.Parse(time.RFC3339Nano, l.ExpiresAt) if err != nil || !now.Before(end) || !end.After(begin) || end.Sub(begin) > c.maxTTL || end.After(callerEnd) { return ErrRefused } ttl, err := time.ParseDuration(l.TTL) if err != nil || l.Kind != api.DecisionLifetimeTTL || ttl <= 0 || ttl > c.maxTTL { return ErrRefused } // Producer CompleteDecision uses its exact decision instant for both bounds. // RFC3339 output can truncate subsecond precision, so do not invent lost time. if !begin.Equal(decision) || end.Sub(begin) != ttl { return ErrRefused } return nil } func sameSubject(a, b api.SubjectRef) bool { return a.ID == b.ID && a.Type == b.Type && a.Tenant == b.Tenant } func sameResource(a, b api.ResourceRef) bool { return a.ID == b.ID && a.Type == b.Type && a.System == b.System && a.Tenant == b.Tenant }