DelegusDocsv0.3 rc1

Docs/Threat model mapping

Trust

Threat model → implementation mapping

Spec §13 is the normative scope of protection. This document maps each threat to where the protection lives in the code and which test proves it, so a security reviewer starts from the design rather than rediscovering it. Paths are relative to the repository root; check ids are spec §5.2/§5.3. Controls that live in the hosting rather than in code (keys, evidence bucket, network, secrets, monitoring) are stated in infra-security.md; this document links to its sections wherever an infrastructure control is named.

Protected in v0 (§13.1)#

IDThreatDelivered by (spec)ImplementationProven by
TM-01Forged GrantP2, P3packages/core/src/diddoc.ts finds the kid in the issuer's DID document snapshot (Multikey, Ed25519, ≤ 5 keys, controller = issuer); packages/core/src/evaluate.ts verifies the Grant JWS with that key onlyvectors deny-issuer-key-not-found, deny-grant-signature-invalid, deny-grant-signature-invalid-wrong-principal-key
TM-02Modified GrantP3JWS signature over the exact received bytes (packages/core/src/jws.ts, strict base64url, 64-byte signature)deny-grant-signature-invalid; test/grant.test.ts malformed set
TM-03Stolen Grant used without the Agent keyP9, P10Proof signature verified against the key its kid/iss names (proof.ts), then iss compared with the Grant's sub (evaluate.ts)deny-proof-signature-invalid, deny-proof-agent-mismatch
TM-04Proof replay at the same RPP13, T5iat within the RP's window (evaluate.ts); atomic SET replay:{rp}:{jti} NX PX 2×window executed only after P1–P14 pass (apps/api/src/service/verify.ts → adapters/redis/replay.ts); store unavailable fails closeddeny-proof-expired, deny-proof-replayed, deny-service-unavailable-replay; apps/api/test/api.test.ts "replay"
TM-05Proof forwarded to another RPP12aud equals the DID the API key resolved to (evaluate.ts; RP identity from apps/api/src/http.ts)deny-proof-audience-mismatch
TM-06Action tampering between Agent and DelegusP14action_hash equals sha256 of JCS(action) as the RP submitted it (jcs.ts, hash.ts); the RP builds the action, never the Agentdeny-proof-action-mismatch; JCS set
TM-07Expired or not-yet-valid authorityP4, P5now from the service clock, recorded as evaluated_at; offline verifiers use the signed value (erratum 2, receipt.ts reverifyReceipt)deny-grant-not-yet-valid, deny-grant-expired; test/receipt.test.ts "now = evaluated_at"
TM-08Revoked authorityP6 + §7 guaranteeDelegus-signed Bitstring Status List verified in status.ts; /revoke commits the re-signed list to Postgres (compare-and-set) then INCR status_ver, success only after both; verify nodes reload whenever the counter is missing or differs from the cached value (apps/api/src/service/status-lists.ts)deny-authority-revoked; apps/api/test/api.test.ts "demo flow … on another node too" incl. Redis failover; deny-service-unavailable-status
TM-09Issuer key compromise after notificationT3key state in the registry, read on every verify (issuerLookup); POST /keys/{kid}/compromise (Delegus's own keys: infra-security § Signing keys)deny-issuer-key-compromised; api test "trust layer"
TM-10Stale identity evidence unrecordedreceipt pins did_document_hash, resolved_atsnapshot bytes stored by hash at resolution and refresh ≤ 5 min (apps/api/src/service/principals.ts); hash and time in every receiptapi test "onboarding … DID snapshot"; offline re-verification tests
TM-11Unknown or unverified issuer; (v0.3) issuer not in good standingT1, T2registry lookup, read from Postgres on every verify (issuerLookup via apps/api/src/service/verify.ts hooksFor); domain verification by DNS TXT or well-known, re-run every 24 h (under delegus-base-v3 a hosted Principal also passes T2 through its org's verified domain claim, and a managed Principal passes T2 with principal.verification managed in its receipt); unregistered → ISSUER_UNKNOWN, registered without a snapshot → SERVICE_UNAVAILABLE. Standing, delegus-trust-v3 (delegus-base-v3 only; packages/core/src/evaluate.ts): a registered Principal whose standing is suspended or disputed fails T1 with ISSUER_NOT_TRUSTED, a signed DENY; if P1–P14 pass, T5 still runs and the Proof's jti is spent, as for any DENY that reaches T5. Relying parties on delegus-base-v1 (delegus-trust-v1) and on the retired delegus-base-v2 (delegus-trust-v2) do not check standing. Standing is set only by staff (admin token) at POST /principals/{did}/standing with state good, suspended or disputed and a required reason (a lift needs one too); stored on principals (standing, standing_reason, standing_at, migration 022) and audited as principal.standing. It changes no receipt already issued: earlier receipts keep their decision and signaturedeny-issuer-unknown, deny-issuer-unverified, v3-deny-issuer-suspended, v3-deny-issuer-disputed, v3-allow-issuer-reinstated; packages/core/test/v03-evaluate.test.ts "v1 is untouched: standing is ignored under delegus-base-v1"; api tests "hosted principal whose DID document cannot be fetched", "a suspended or disputed principal: under v3 a signed DENY ISSUER_NOT_TRUSTED; lifted: ALLOW"
TM-12Status-list substitution or forgeryP6 Delegus-signed; receipt pins credential_hashstatus.ts requires kid in the injected Delegus status keys, exact header/type/context, id = the Grant's URL, issuer = the service DIDtest/evaluate.test.ts, vectors with revoked lists; substituted artifacts detected in test/receipt.test.ts
TM-13Grant used at an excluded RPP7audience, if present, must contain the RP DIDdeny-audience-mismatch
TM-14Evidence altered after the factcontent-addressed storekeys sha256/<hex>, write-once PUT, Object Lock (infra-security § Evidence store); GET /evidence/{hash}; offline verifier compares every pinned hash (receipt.ts)test/receipt.test.ts "substituted artifacts are detected by hash"; api test "offline re-verification from GET /evidence alone"
TM-15(v0.3) Relying on a prior decision that was forged, altered or never issuedD2, D3 (delegus-base-v3)a relies_on entry names the prior receipt by a commitment (packages/core/src/commitment.ts: sha256 over a length-prefixed tag, a 32-byte nonce and the receipt's exact JWS bytes), never by an id; the service resolves each declared fingerprint to the receipt it stored with that exact fingerprint (apps/api/src/service/verify.ts dependencyLookup, unique index in migration 017) and D2 recomputes the commitment over the stored bytes (packages/core/src/dependencies.ts); D3 verifies the stored receipt's signature under the Delegus receipt keys and that its body equals the signed payload; an unknown fingerprint, an opening that does not open, or an unavailable store all fail closedv3-deny-dependency-wrong-opening, v3-deny-dependency-receipt-invalid, v3-deny-dependency-stored-receipt-not-jws, v3-deny-dependency-store-unavailable; packages/core/test/v03-evaluate.test.ts "(D2)", "(D3)", "the dependency store unavailable … fail closed"; api test v03-verify.test.ts "an unknown fingerprint is D2"
TM-16(v0.3) Relying on a prior DENY as if it were an ALLOWD4the stored receipt's own decision must be ALLOW (dependencies.ts); a DENY receipt is also never stored with a fingerprint or a handle, so it cannot be declared at all (verify.ts)v3-deny-dependency-not-allow; core test "(D4)"; api test "a DENY carries no handle and gets no opening"
TM-17(v0.3) Splicing dependencies from unrelated transactions into one actionD5every resolved dependency must carry the same Delegus-internal transaction id as the first (dependencies.ts); the id lives only in the database (migration 017), never in a receipt or response, so an actor cannot forge membershipv3-deny-dependency-other-transaction, v3-deny-multi-entry-second-fails, v3-allow-eight-dependencies; core test "(D5)"; api test "a chain across two sellers … the entries match relies_on_hash"
TM-18(v0.3) Relying on authority that expired, was revoked, whose Principal was suspended, or whose signing key was compromised after the prior decisionD6, D7 (CURRENT_AUTHORITY only)the dependency Grant's validity is checked against the same now as the action (D6); its status credential, its Principal's standing and its key state are read in the same pre-P6 step as the action's own status list, so the replay write never precedes them (erratum 1 order, verify.ts; dependencies.ts); a missing standing or status is unknown and fails closed; PRIOR_DECISION skips D6/D7 by design, so a revoke never rewrites historyv3-deny-dependency-expired, v3-deny-current-authority-after-revoke, v3-deny-dependency-issuer-suspended, v3-deny-dependency-issuer-disputed, v3-deny-dependency-issuer-key-compromised, v3-deny-dependency-standing-unknown, v3-allow-prior-decision-after-revoke; api tests "after the dependency's Grant is revoked: CURRENT_AUTHORITY is D7, PRIOR_DECISION still holds", "review H5: … later compromised is D7", "… later suspended is D7 …; lifted, ALLOW again"
TM-19(v0.3) An actor omitting a dependency the relying party requires, or weakening its modeD1; requires_hashevery requires entry must appear in the actor's relies_on with the same fingerprint and the same mode (dependencies.ts); the receipt records requires_hash = sha256 of JCS(requires) so the requirement is reproducible offline (evaluate.ts, receipt.ts re-verification); requires is accepted only from a relying party on delegus-base-v3 and must be well formed (verify.ts)v3-deny-requires-undeclared, v3-deny-requires-stronger-mode; core test "a requirement the actor did not declare: DEPENDENCY_NOT_DECLARED (D1); a mode mismatch counts as undeclared"; api tests "requires the actor did not declare is D1", "requires is refused for a relying party not on v3, and must be well formed"
TM-20(v0.3) An actor inserting or altering declared dependencies after signingP14, P15relies_on is a member of the action, so it is inside the action_hash the Agent signed; a relying party that adds, drops or edits an entry fails P14 before any dependency check runs; the shape is strict at P15 (1–8 entries, no repeats, known modes, no extra members)v3-deny-relying-party-inserted-dependency; v3-deny-relies-on-empty, -extra-member, -nine, -no-mode, -repeated, -unknown-mode, v3-deny-transaction-field; packages/core/test/v03-actions-commitment.test.ts "v0.3 actions (P15)"
TM-21(v0.3) Spending beyond a budget, including concurrent requests racing for the last headroomP21, T7P21 evaluates the budget against a consumption snapshot supplied by the service (packages/core/src/consumption.ts; absent snapshot → SERVICE_UNAVAILABLE); the spend itself is a conditional UPDATE … WHERE spent + amount ≤ limit under a per-Grant FOR UPDATE row lock, idempotent on the receipt id, so two requests that both passed P21 on the same snapshot cannot both take the last headroom: one is consumed, the other is AUTHORITY_EXHAUSTED at T7 (apps/api/src/adapters/pg/registry.ts consumeIn, apps/api/src/service/consumption.ts)v3-budget-exhausted-total, v3-budget-last-headroom-lost, v3-budget-snapshot-missing; packages/core/test/v03-budgets.test.ts "last headroom under contention: the atomic seam admits one; the rest are refused …"; memory-consumption.test.ts "(b) a lost race through the seam"; api test consumable-verify.test.ts "budget decrements per ALLOW; exhaustion denies AUTHORITY_EXHAUSTED at the right dimension"
TM-22(v0.3) Budget spent by a request that is then refused[consume] lastthe consume seam is called only after every other check in the profile's order is true, including T5 and D1–D7; on any DENY it is never called and T7 stays skipped (evaluate.ts); a seam that is unavailable fails closed at T7; a snapshot-store or signer failure is a 503 with nothing spent (verify.ts)v3-budget-no-spend-on-dependency-deny, v3-budget-no-spend-when-replayed, v3-budget-no-spend-when-revoked, v3-budget-consume-unavailable; packages/core/test/consume-gate.test.ts "every DENY path with a budget makes zero consume calls and leaves T7 skipped", "an ALLOW spends exactly once, as the last step"; api tests "budgets: a DENY by a dependency check spends nothing; a replayed Proof spends nothing; an ALLOW spends", "reviews M2/M3: a request refused before the spend writes no snapshot and spends nothing; …"
TM-23(v0.3) Discovering or enumerating receipts from a fingerprint or transaction handle§R.1 commitments, §R.3 pairwise handles, §8 uniform 404a fingerprint hides the receipt behind a 32-byte nonce returned only to the receipt's relying party (commitment.ts); handles are per-receipt random output-only values, so two sellers in one transaction hold different handles (verify.ts); no API route takes a fingerprint or a handle as input, and the only lookup by fingerprint is the exact-match resolution inside /verify (migration 017); receipt reads resolve the caller's entitled subjects before looking the receipt up and answer the same 404 for an unknown receipt and for someone else's (apps/api/src/service/disclosure.ts); declaring a fingerprint entitles nobody to the prior receiptpackages/core/test/v03-actions-commitment.test.ts "domain separation", "openings decode strictly", "transaction handles …"; api tests "a chain across two sellers … handles are pairwise", "§8: the receipt's principal reads it; anyone else gets the same 404", "review B1: declaring a fingerprint never entitles anyone to the prior receipt", "review #2 M5: … another org's key gets the same 404"
TM-24(v0.3) Learning the plaintext authority or dependency evidence from a receipt alonepinned by hash and commitment; plaintext only beside the receipt and in the entitled packagea delegus-receipt-v2 carries authority_commitment and capability_key_commitment (nonce-blinded, because a Capability has too little entropy for a plain hash to hide it; review H2) and evidence_commitment for the dependency evidence (commitment.ts, evaluate.ts); the openings and the matched Capability are returned beside the body on the /verify response and on entitled reads only (apps/api/src/service/openings.ts); the private record and the receipt JWS are stored in the evidence store as private kinds that GET /evidence/{hash} never serves (apps/api/src/service/receipt-evidence.ts); the §R.5 disclosure package goes to the receipt's relying party and Principal only, and none is kept for a DENY (disclosure.ts)packages/core/test/v03-evaluate.test.ts "an ALLOW pins the authority by hash; the plaintext Capability comes back beside the body"; v03-commitment-set.test.ts "authority and capability-key commitments reproduce, open, and hide the plain hash (review H2)"; api tests receipt-evidence.test.ts "a v3 ALLOW: the private record … is stored by hash, recorded on the row, and never served", "the consumption snapshot, the receipt JWS and the private record are all refused by GET /evidence"; v03-verify.test.ts "the disclosure package (§R.5): … nobody else learns it exists", "review B1: no disclosure package is kept for a DENY"

Fail-closed properties that cut across the table: unknown header parameters, claims, members and constraint values deny at P1/P8 (grant.ts, proof.ts); non-canonical htu denies (htu.ts); duplicate JSON keys and unsafe integers are rejected by the strict parser (json.ts); every unavailable or throwing service seam yields SERVICE_UNAVAILABLE (evaluate.ts safely()); the decision is the first false in the fixed §5.4 order and T5 never runs before P14.

Explicitly not protected (§13.2) and where the boundary is enforced#

IDNot protectedWhere the boundary shows
NP-01Malicious Principal authorizing malicious activityDelegus attests authority only; no content inspection anywhere in core
NP-02Compromised active Principal key before notificationbounded by validUntil ≤ validFrom + 90 d (P1) and ≤ 5 keys (P2)
NP-03Compromised Agent keybounded by Grant scope (P16–P20) and expiry; SDK signer is pluggable for KMS/HSM
NP-04Business fraud unrelated to authorityout of scope; outcomes are recorded, never adjudicated
NP-05Semantic ambiguity of actionsclosed vocabulary (capability.ts); unknown constraint keys deny (P17); the RP owns the mapping
NP-06False Outcome Attestationsappend-only, reporter-tagged (apps/api/src/service/outcomes.ts); no adjudication
NP-07Lookalike domainsdomain control ≠ organizational identity; no KYB in v0
NP-08RP misreporting the actionthe receipt records the RP's own submission (request_hash)
NP-09Compromise of Delegus signing keysGoogle Cloud KMS custody (infra-security § Signing keys); keys retired, never removed; two-roll rotation drilled on dev, documented in apps/api/README.md
NP-10DNS or web PKI compromiseshared with TLS; out of scope

Service-layer surface (not in §13; for the reviewer)#

SurfaceControlCodeTest
API keys256-bit random, stored as sha256, shown once; rotation and revocation; revoked keys dead immediatelyapps/api/src/ids.ts, http.ts, /api-keys routes in server.tsapi test "api keys"
Admin tokenrequired outside local/test; onboarding and staff routes only (including standing); set as a service environment variable (infra-security § Secrets and deployments)config.ts, http.ts authAdminapi test "registry admin"
Relying-party standing (v0.3-rc1)a relying party whose standing is suspended or disputed is refused before evaluation on POST /verify, POST /access/v1/evaluation and POST /orgs/self-serve/first-receipt: HTTP 403 RP_SUSPENDED, no receipt, no replay write (the Proof is not spent); applies on every profile. The same gate refuses a customer-hosted relying party whose org does not hold a verified claim on its domain (403 RP_UNVERIFIED). Standing is set only by staff (admin token) at POST /relying-parties/{did}/standing with a required reason, stored on relying_parties (migration 022), audited as relying_party.standing; it changes no receipt already issued, and it appears on the subject's own views, never on the public directoryserver.ts gateRelyingParty, setStanding, standingJsonapi tests "hosted relying parties: claim first, verified before /verify, grace then refused; suspended refused; …", "re-review L2: the self-serve first receipt passes the same relying-party gate as /verify"
Rate limitsper relying party on /verify, per key on other authenticated routes, per IP on public routes and on failed authentication; sandbox 30/min per IP; no web application firewall today (infra-security § Network and edge)ratelimit.ts, http.ts, server.ts, service/demo.tsapi test "rate limits"; sandbox test
Request bodiesstrict JSON, 64 KB limit, exact envelope for /verifyhttp.ts, service/verify.tsapi test "request envelope validation"
Outbound fetches (SSRF)only at onboarding and maintenance, never on /verify; https only, 10 s timeout, 64 KB cap; URLs derived from did:web rules only, DID domain must not be a Delegus domain for hosted Principalsadapters/network.ts, didweb.ts, service/principals.tsunits test "did:web resolution rules"; api test "hosted principal"
Host separationstatus host serves lists only; DID-domain host serves DID documents onlyserver.tsapi test "the DID domain host serves DID documents only"
SandboxDELEGUS_DEMO=1 only, refused in prod; ephemeral identities shared via a write-once setting; CORS for configured origins onlyservice/demo.ts, config.tssandbox tests
Loggingone structured line per request; never keys, Grants, Proofs or receipt bodies; request ids echoed; metric filters and alarms on the log group (infra-security § Logging and alerting)log.ts, server.tsapi test "every response carries x-request-id"
Auditappend-only, no update or delete path existsservice/audit.ts, migration 005api test "audit log"
Postgres / RedisTLS with CA verification (RDS bundle), fail-fast Redis with no offline queue; unavailability → 503 or SERVICE_UNAVAILABLE; private subnets only (infra-security § Network and edge)adapters/pg/registry.ts (pgPoolConfig), adapters/redis/replay.tsunits test "pg: an explicit CA survives…"; api test "service unavailable at each seam"

Suggested pen-test order#

  1. /verify with hostile Grants, Proofs and Actions (the vectors in packages/core/test/vectors/cases are the baseline; try what they do not).
  2. Replay and revocation under concurrency across two API tasks.
  3. Onboarding: API key handling, admin token, did:web resolution and domain verification (SSRF, redirects, oversized documents).
  4. Public endpoints: evidence, status lists, DID documents (enumeration, cache behavior, rate limits).
  5. The sandbox on dev (abuse, CORS, exhaustion).
  6. The SDK: JWS handling, binding check, key files.

Out of scope for this list and covered separately: the hosting controls in infra-security.md (key custody, evidence bucket, secrets, deployment, monitoring) and the marketing site.