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-; this document links to its sections wherever an infrastructure control is named.
Protected in v0 (§13.1)#
| ID | Threat | Delivered by (spec) | Implementation | Proven by |
|---|---|---|---|---|
| TM-01 | Forged Grant | P2, P3 | packages/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 only | vectors deny-issuer-key-not-found, deny-grant-signature-invalid, deny-grant-signature-invalid-wrong-principal-key |
| TM-02 | Modified Grant | P3 | JWS 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-03 | Stolen Grant used without the Agent key | P9, P10 | Proof 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-04 | Proof replay at the same RP | P13, T5 | iat 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 closed | deny-proof-expired, deny-proof-replayed, deny-service-unavailable-replay; apps/api/test/api.test.ts "replay" |
| TM-05 | Proof forwarded to another RP | P12 | aud equals the DID the API key resolved to (evaluate.ts; RP identity from apps/api/src/http.ts) | deny-proof-audience-mismatch |
| TM-06 | Action tampering between Agent and Delegus | P14 | action_hash equals sha256 of JCS(action) as the RP submitted it (jcs.ts, hash.ts); the RP builds the action, never the Agent | deny-proof-action-mismatch; JCS set |
| TM-07 | Expired or not-yet-valid authority | P4, P5 | now 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-08 | Revoked authority | P6 + §7 guarantee | Delegus-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-09 | Issuer key compromise after notification | T3 | key 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-10 | Stale identity evidence unrecorded | receipt pins did_document_hash, resolved_at | snapshot bytes stored by hash at resolution and refresh ≤ 5 min (apps/api/src/service/principals.ts); hash and time in every receipt | api test "onboarding … DID snapshot"; offline re-verification tests |
| TM-11 | Unknown or unverified issuer; (v0.3) issuer not in good standing | T1, T2 | registry 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 signature | deny-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-12 | Status-list substitution or forgery | P6 Delegus-signed; receipt pins credential_hash | status.ts requires kid in the injected Delegus status keys, exact header/type/context, id = the Grant's URL, issuer = the service DID | test/evaluate.test.ts, vectors with revoked lists; substituted artifacts detected in test/receipt.test.ts |
| TM-13 | Grant used at an excluded RP | P7 | audience, if present, must contain the RP DID | deny-audience-mismatch |
| TM-14 | Evidence altered after the fact | content-addressed store | keys 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 issued | D2, 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 closed | v3-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 ALLOW | D4 | the 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 action | D5 | every 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 membership | v3-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 decision | D6, 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 history | v3-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 mode | D1; requires_hash | every 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 signing | P14, P15 | relies_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 headroom | P21, T7 | P21 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] last | the 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 404 | a 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 receipt | packages/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 alone | pinned by hash and commitment; plaintext only beside the receipt and in the entitled package | a 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_ (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#
| ID | Not protected | Where the boundary shows |
|---|---|---|
| NP-01 | Malicious Principal authorizing malicious activity | Delegus attests authority only; no content inspection anywhere in core |
| NP-02 | Compromised active Principal key before notification | bounded by validUntil ≤ validFrom + 90 d (P1) and ≤ 5 keys (P2) |
| NP-03 | Compromised Agent key | bounded by Grant scope (P16–P20) and expiry; SDK signer is pluggable for KMS/HSM |
| NP-04 | Business fraud unrelated to authority | out of scope; outcomes are recorded, never adjudicated |
| NP-05 | Semantic ambiguity of actions | closed vocabulary (capability.ts); unknown constraint keys deny (P17); the RP owns the mapping |
| NP-06 | False Outcome Attestations | append-only, reporter-tagged (apps/api/src/service/outcomes.ts); no adjudication |
| NP-07 | Lookalike domains | domain control ≠ organizational identity; no KYB in v0 |
| NP-08 | RP misreporting the action | the receipt records the RP's own submission (request_hash) |
| NP-09 | Compromise of Delegus signing keys | Google Cloud KMS custody (infra-security § Signing keys); keys retired, never removed; two-roll rotation drilled on dev, documented in apps/api/README.md |
| NP-10 | DNS or web PKI compromise | shared with TLS; out of scope |
Service-layer surface (not in §13; for the reviewer)#
| Surface | Control | Code | Test |
|---|---|---|---|
| API keys | 256-bit random, stored as sha256, shown once; rotation and revocation; revoked keys dead immediately | apps/api/src/ids.ts, http.ts, /api-keys routes in server.ts | api test "api keys" |
| Admin token | required 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 authAdmin | api 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 directory | server.ts gateRelyingParty, setStanding, standingJson | api 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 limits | per 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.ts | api test "rate limits"; sandbox test |
| Request bodies | strict JSON, 64 KB limit, exact envelope for /verify | http.ts, service/verify.ts | api 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 Principals | adapters/network.ts, didweb.ts, service/principals.ts | units test "did:web resolution rules"; api test "hosted principal" |
| Host separation | status host serves lists only; DID-domain host serves DID documents only | server.ts | api test "the DID domain host serves DID documents only" |
| Sandbox | DELEGUS_DEMO=1 only, refused in prod; ephemeral identities shared via a write-once setting; CORS for configured origins only | service/demo.ts, config.ts | sandbox tests |
| Logging | one 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.ts | api test "every response carries x-request-id" |
| Audit | append-only, no update or delete path exists | service/audit.ts, migration 005 | api test "audit log" |
| Postgres / Redis | TLS 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.ts | units test "pg: an explicit CA survives…"; api test "service unavailable at each seam" |
Suggested pen-test order#
/verifywith hostile Grants, Proofs and Actions (the vectors inpackages/are the baseline; try what they do not).core/ test/ vectors/ cases - Replay and revocation under concurrency across two API tasks.
- Onboarding: API key handling, admin token, did:web resolution and domain verification (SSRF, redirects, oversized documents).
- Public endpoints: evidence, status lists, DID documents (enumeration, cache behavior, rate limits).
- The sandbox on dev (abuse, CORS, exhaustion).
- The SDK: JWS handling, binding check, key files.
Out of scope for this list and covered separately: the hosting controls in infra- (key custody, evidence bucket, secrets, deployment, monitoring) and the marketing site.