@synoi/sraid 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/authority.ts DELETED
@@ -1,849 +0,0 @@
1
- /**
2
- * @synoi/sraid — authority.ts
3
- *
4
- * L4 authority VERIFIER for CDRO objects (the authorized axis).
5
- *
6
- * This is deliberately more than a shape check. Per the Adversary review
7
- * (panel A6: "ship a verifier, not just a schema"), an attacker who can
8
- * attach any well-shaped `authority` block to an object would otherwise
9
- * forge authorization for free. So this module verifies, locally and
10
- * offline, the parts of authorization that ARE locally checkable:
11
- *
12
- * 1. STRUCTURE — the object carries a present, well-formed authority
13
- * block (grant reference present; decision verb, if any, is a valid
14
- * GAP verb; intent ref well-formed).
15
- * 2. BINDING — the object's authority block actually references the
16
- * supplied grant (the grant's OID matches `authority.grant_oid`),
17
- * AND the grant is itself a hash-honest CDRO whose recomputed OID
18
- * equals its claimed OID (so the grant body cannot be swapped under
19
- * a fixed OID reference).
20
- * 3. SIGNATURE — the grant carries a valid hybrid (Ed25519 + ML-DSA-65)
21
- * signature over its own content core, when grant signing material
22
- * and verifier public keys are supplied. A grant with no/invalid
23
- * signature does not authorize.
24
- * 4. COVERAGE — the grant's capability scopes cover the requested
25
- * object type / action (dotted-taxonomy match with segment-boundary
26
- * wildcards), and the grant has not expired relative to the object's
27
- * creation time. Both are computable from the bytes in hand.
28
- *
29
- * What this module does NOT and CANNOT do offline — stated honestly,
30
- * never silently assumed (CLAIMS_DISCIPLINE):
31
- *
32
- * - LIVE REVOCATION — whether the grant has since been revoked.
33
- * - EXISTENCE — whether the grant OID actually resolves to a
34
- * published, retrievable grant at all (when the
35
- * caller did not supply the grant material).
36
- *
37
- * Both require the OID Resolver, which is presently undeployed
38
- * (SRAID_FOUNDATION_PUNCHLIST C). This module DEFINES the resolver
39
- * interface (`AuthorityResolver`) and, when a resolver is supplied, calls
40
- * it and folds its answer into the result — but when no resolver is
41
- * supplied, the result is explicitly marked `revocation_checked: false`
42
- * and `existence_checked: false` so a caller can never mistake a
43
- * locally-passing verification for a live one.
44
- *
45
- * DELEGATION CHAINS (`verifyDelegationChain`, K2) — VERIFY-ONLY. This module
46
- * also carries a synchronous, offline delegation-chain verifier. It is
47
- * deliberately scoped to verification with NO enforcement wiring and NO
48
- * resolver: the caller supplies the ordered ancestors and the per-link
49
- * verifier keys, and the function makes NO live claim. It ALWAYS returns
50
- * `revocation_checked: false`, `not_revoked: false`, and
51
- * `existence_checked: false` — those fields are LITERAL-TYPED `false` so the
52
- * type system itself forbids a future edit from quietly asserting a live,
53
- * resolver-backed claim from an offline function (CLAIMS_DISCIPLINE made
54
- * structural). Synchronicity is intentional: with no Promise overload there is
55
- * no place for a resolver call, which is what makes the no-live-claim
56
- * guarantee structural rather than merely documented.
57
- */
58
-
59
- import { cdroOid, cdroContentCore } from './oid.js'
60
- import { verifySignature } from './signature.js'
61
- import { verifyAttestation } from './attestation.js'
62
- import type {
63
- AuthorityBlock,
64
- AuthorityDecision,
65
- CDRO,
66
- SignatureEnvelope,
67
- } from './types.js'
68
-
69
- // ── Valid verb set (mirrors AuthorityDecision in types.ts) ────────────────────
70
-
71
- const VALID_DECISIONS: ReadonlySet<string> = new Set<AuthorityDecision>([
72
- 'allow',
73
- 'deny',
74
- 'defer',
75
- 'step_up',
76
- 'delegate',
77
- 'revoke',
78
- ])
79
-
80
- // ── Capability pattern matching ───────────────────────────────────────────────
81
-
82
- /**
83
- * Match a capability `target` against a grant `pattern`. Pure string logic,
84
- * re-stated here so L0 stays dependency-free (the same rule lives in
85
- * `@synoi/gap-types` `capabilityMatches`; L0 must not depend on L3).
86
- *
87
- * - exact match → true
88
- * - '*' → match-all
89
- * - 'skill.*' matches 'skill.create' and deeper (segment-boundary only).
90
- * A non-boundary 'admin.us*' must NOT match 'admin.users.delete'
91
- * (privilege-escalation footgun) — only a '.'-anchored '*' is a wildcard.
92
- */
93
- export function capabilityCovers(pattern: string, target: string): boolean {
94
- if (pattern === target) return true
95
- if (pattern === '*') return true
96
- if (pattern.endsWith('.*')) {
97
- const prefix = pattern.slice(0, -1) // keep trailing '.', e.g. 'skill.'
98
- return target.startsWith(prefix)
99
- }
100
- return false
101
- }
102
-
103
- // ── Resolver interface (live revocation / existence — resolver-dependent) ─────
104
-
105
- /**
106
- * The live-state interface that the OID Resolver implements. It is the ONLY
107
- * source of truth for the two properties that cannot be checked offline:
108
- * whether a grant currently exists (resolves) and whether it has been
109
- * revoked. RESOLVER-DEPENDENT: undeployed today
110
- * (SRAID_FOUNDATION_PUNCHLIST C). Defined here so callers and a future
111
- * resolver agree on the contract; verifyAuthority works without it but
112
- * marks the corresponding result fields as not-checked.
113
- */
114
- export interface AuthorityResolver {
115
- /**
116
- * Resolve a grant OID to its current status. Implementations should
117
- * return `{ exists: false }` for an unknown OID and `{ exists: true,
118
- * revoked: true, revoked_at_ms }` for a revoked one.
119
- */
120
- resolveGrantStatus(grantOid: string): Promise<GrantStatus> | GrantStatus
121
- }
122
-
123
- export interface GrantStatus {
124
- /** Whether the grant OID resolves to a known, published grant. */
125
- exists: boolean
126
- /** Whether the grant has been revoked. Only meaningful when `exists`. */
127
- revoked?: boolean
128
- /** When the revocation took effect, if revoked. */
129
- revoked_at_ms?: number
130
- }
131
-
132
- // ── verifyAuthority ───────────────────────────────────────────────────────────
133
-
134
- export interface VerifyAuthorityInput {
135
- /**
136
- * The object whose authority block is being verified. Its `authority`
137
- * field is read; the object's `type` is used as the action target for the
138
- * coverage check unless `action` overrides it.
139
- */
140
- object: CDRO
141
- /**
142
- * The capability / action the object claims to perform, for the coverage
143
- * check. Defaults to `object.type`. For an Althing receipt this is
144
- * typically the invoked capability (e.g. `email.bulk_delete`).
145
- */
146
- action?: string
147
- /**
148
- * The authorizing grant CDRO, when available. Supplying it enables the
149
- * BINDING, SIGNATURE, and COVERAGE checks. Omit it to do STRUCTURE-only
150
- * verification (then `grant_supplied: false`).
151
- */
152
- grant?: CDRO<GrantBodyShape>
153
- /** Verifier public keys for the grant's hybrid signature. */
154
- grant_ed25519_pub?: Uint8Array
155
- grant_ml_dsa_pub?: Uint8Array
156
- /**
157
- * Expected DSSE payloadType for the grant's attestation envelope. Defaults
158
- * to `'application/vnd.synoi.sraid+json'`. Override when the grant was
159
- * issued with a custom media type.
160
- */
161
- grant_payload_type?: string
162
- /**
163
- * Optional live-state resolver. When supplied, revocation + existence are
164
- * checked and folded into the result; when omitted those remain
165
- * explicitly unchecked (resolver-dependent).
166
- */
167
- resolver?: AuthorityResolver
168
- }
169
-
170
- /**
171
- * The minimum grant body shape this verifier reads for coverage + expiry.
172
- * A superset of `@synoi/gap-types` `CapabilityGrantBody`; kept structural so
173
- * L0 does not depend on L3.
174
- */
175
- export interface GrantBodyShape {
176
- capability_scopes?: Array<{ capability?: unknown }>
177
- expires_at_ms?: number | null
178
- /**
179
- * Issuer of this grant. Mirrors the gateway `CapabilityGrantBody.granted_by`
180
- * (synoi-gateway/src/gap/types.ts). VERIFY-ONLY: read by
181
- * `verifyDelegationChain` to check that a child's issuer equals its parent's
182
- * grantee. Kept OPTIONAL/structural so L0 stays L3-independent.
183
- */
184
- granted_by?: string
185
- /**
186
- * Subject this grant is issued to. Mirrors the gateway
187
- * `CapabilityGrantBody.grantee.actor_oid`. VERIFY-ONLY: a child grant chains
188
- * under this grant iff `child.granted_by === this.grantee.actor_oid`.
189
- */
190
- grantee?: { actor_oid?: string }
191
- /**
192
- * OID of the parent grant in a delegation chain. Absent/null = a root grant.
193
- * VERIFY-ONLY linkage field; it is NOT consulted for any enforcement here.
194
- * The chain order and ancestry are caller-supplied to `verifyDelegationChain`
195
- * (the verifier never fetches), so this is informational/auditable, not a
196
- * resolution hook.
197
- */
198
- parent_grant_oid?: string | null
199
- }
200
-
201
- export interface VerifyAuthorityResult {
202
- /**
203
- * True only when every LOCALLY CHECKABLE step that was attempted passed.
204
- * NOTE: this is `false` for `valid` does NOT imply the grant is revoked;
205
- * read the per-check fields. Crucially, `authorized === true` from this
206
- * function means "locally authorized" — it is NOT a claim about live
207
- * revocation unless `revocation_checked` is also true.
208
- */
209
- authorized: boolean
210
- /** Structure check: authority block present + well-formed. */
211
- structure_ok: boolean
212
- /** Whether a grant was supplied (enables binding/signature/coverage). */
213
- grant_supplied: boolean
214
- /** Binding check: object.authority.grant_oid === grant's recomputed OID. */
215
- binding_ok: boolean
216
- /** Signature check: grant carries a valid hybrid signature (when keys given). */
217
- signature_ok: boolean
218
- /** Whether signature was actually checked (keys + grant present). */
219
- signature_checked: boolean
220
- /** Coverage check: grant scope covers the action AND grant not expired. */
221
- coverage_ok: boolean
222
- /** RESOLVER-DEPENDENT — true only if a resolver confirmed the grant exists. */
223
- existence_checked: boolean
224
- existence_ok: boolean
225
- /** RESOLVER-DEPENDENT — true only if a resolver confirmed not-revoked. */
226
- revocation_checked: boolean
227
- not_revoked: boolean
228
- /** Human-readable failure reasons. Empty when fully authorized. */
229
- reasons: string[]
230
- }
231
-
232
- const DEFAULT_GRANT_PAYLOAD_TYPE = 'application/vnd.synoi.sraid+json'
233
-
234
- /**
235
- * Verify the authority of a CDRO object. Synchronous local checks plus an
236
- * optional resolver call. When a resolver is supplied this returns a
237
- * Promise; otherwise it returns the result directly.
238
- */
239
- export function verifyAuthority(
240
- input: VerifyAuthorityInput & { resolver?: undefined },
241
- ): VerifyAuthorityResult
242
- export function verifyAuthority(
243
- input: VerifyAuthorityInput & { resolver: AuthorityResolver },
244
- ): Promise<VerifyAuthorityResult>
245
- export function verifyAuthority(
246
- input: VerifyAuthorityInput,
247
- ): VerifyAuthorityResult | Promise<VerifyAuthorityResult> {
248
- const reasons: string[] = []
249
- const auth: AuthorityBlock | undefined = input.object?.authority
250
-
251
- // ── 1. STRUCTURE ──────────────────────────────────────────────────────────
252
- let structure_ok = true
253
- if (auth === undefined || auth === null || typeof auth !== 'object') {
254
- structure_ok = false
255
- reasons.push('authority block absent — object asserts no authority')
256
- } else {
257
- if (auth.grant_oid !== undefined) {
258
- if (typeof auth.grant_oid !== 'string' || !auth.grant_oid.startsWith('sha256:')) {
259
- structure_ok = false
260
- reasons.push('authority.grant_oid must be a "sha256:" OID')
261
- }
262
- } else {
263
- // grant_oid is the only thing that ties an object to an authorizer.
264
- // Its absence is allowed ONLY for an uncorrelated state-change event,
265
- // which is the orphan case — not "authorized".
266
- structure_ok = false
267
- reasons.push('authority.grant_oid absent — orphaned / uncorrelated authority')
268
- }
269
- if (
270
- auth.decision !== undefined &&
271
- auth.decision !== null &&
272
- !VALID_DECISIONS.has(auth.decision)
273
- ) {
274
- structure_ok = false
275
- reasons.push(`authority.decision "${String(auth.decision)}" is not a valid GAP verb`)
276
- }
277
- if (
278
- auth.intent_oid !== undefined &&
279
- (typeof auth.intent_oid !== 'string' || !auth.intent_oid.startsWith('sha256:'))
280
- ) {
281
- structure_ok = false
282
- reasons.push('authority.intent_oid, if present, must be a "sha256:" OID')
283
- }
284
- }
285
-
286
- // ── 2-4. BINDING / SIGNATURE / COVERAGE (need the grant) ─────────────────────
287
- const grant = input.grant
288
- const grant_supplied = grant !== undefined && grant !== null
289
- let binding_ok = false
290
- let signature_ok = false
291
- let signature_checked = false
292
- let coverage_ok = false
293
-
294
- if (structure_ok && grant_supplied) {
295
- // BINDING — recompute the grant's OID over its content core and require it
296
- // to equal both the grant's own claimed OID and the object's reference.
297
- // This defeats "swap the grant body under a fixed OID reference".
298
- let recomputed: string | null = null
299
- try {
300
- recomputed = cdroOid(grant)
301
- } catch {
302
- recomputed = null
303
- }
304
- const claimedOid = (grant as CDRO).oid
305
- const referenced = auth?.grant_oid
306
- if (recomputed === null) {
307
- reasons.push('grant content core not hashable')
308
- } else if (recomputed !== claimedOid) {
309
- reasons.push('grant OID does not match its content (tampered grant body)')
310
- } else if (recomputed !== referenced) {
311
- reasons.push('object.authority.grant_oid does not reference the supplied grant')
312
- } else {
313
- binding_ok = true
314
- }
315
-
316
- // SIGNATURE — verify the grant's hybrid signature over its content core.
317
- // BINDING and SIGNATURE must hash identical bytes: both route through
318
- // cdroContentCore (strips oid, signature, attestation) so they are
319
- // provably the same bytes.
320
- if (
321
- input.grant_ed25519_pub !== undefined &&
322
- input.grant_ml_dsa_pub !== undefined
323
- ) {
324
- signature_checked = true
325
- const att = (grant as CDRO).attestation
326
- const sig: SignatureEnvelope | undefined = (grant as CDRO).signature
327
-
328
- // Compute the canonical content core once — shared by both paths.
329
- let canonical: string | null = null
330
- try {
331
- canonical = canonicalize(cdroContentCore(grant))
332
- } catch {
333
- canonical = null
334
- }
335
-
336
- if (canonical === null) {
337
- reasons.push('grant content core not canonicalizable for signature check')
338
- } else if (att) {
339
- // DSSE attestation path — prefer when present.
340
- // payload-swap guard: the signed payload MUST equal this grant's content core.
341
- if (att.payload !== canonical) {
342
- signature_ok = false
343
- reasons.push('grant attestation payload does not match its content core (payload swap)')
344
- } else {
345
- const expectedPayloadType = input.grant_payload_type ?? DEFAULT_GRANT_PAYLOAD_TYPE
346
- const r = verifyAttestation({
347
- envelope: att,
348
- ed25519_pub: input.grant_ed25519_pub,
349
- ml_dsa_pub: input.grant_ml_dsa_pub,
350
- expectedPayloadType,
351
- })
352
- signature_ok = r.valid
353
- if (!r.valid) reasons.push(`grant attestation invalid: ${r.reasons.join(',')}`)
354
- }
355
- } else if (sig) {
356
- // Legacy signature envelope path.
357
- const r = verifySignature({
358
- canonical,
359
- envelope: sig,
360
- ed25519_pub: input.grant_ed25519_pub,
361
- ml_dsa_pub: input.grant_ml_dsa_pub,
362
- })
363
- signature_ok = r.valid
364
- if (!r.valid) reasons.push(`grant signature invalid: ${r.reasons.join(',')}`)
365
- } else {
366
- reasons.push('grant carries no signature or attestation')
367
- }
368
- }
369
-
370
- // COVERAGE — scope covers the action, and grant not expired at object time.
371
- const action = input.action ?? input.object.type
372
- const scopes = grant.body?.capability_scopes
373
- let covered = false
374
- if (Array.isArray(scopes)) {
375
- for (const s of scopes) {
376
- if (s && typeof s.capability === 'string' && capabilityCovers(s.capability, action)) {
377
- covered = true
378
- break
379
- }
380
- }
381
- }
382
- if (!covered) {
383
- reasons.push(`grant scope does not cover action "${action}"`)
384
- }
385
- const expires = grant.body?.expires_at_ms
386
- let notExpired = true
387
- if (typeof expires === 'number') {
388
- if (input.object.created_at_ms > expires) {
389
- notExpired = false
390
- reasons.push('grant had expired at the object creation time')
391
- }
392
- }
393
- coverage_ok = covered && notExpired
394
- } else if (structure_ok && !grant_supplied) {
395
- reasons.push('grant not supplied — binding/signature/coverage not checked')
396
- }
397
-
398
- // Local verdict: every attempted local check passed. Signature counts only
399
- // if it was checked; resolver checks are handled below.
400
- const localAuthorized =
401
- structure_ok &&
402
- grant_supplied &&
403
- binding_ok &&
404
- coverage_ok &&
405
- (!signature_checked || signature_ok)
406
-
407
- const base: VerifyAuthorityResult = {
408
- authorized: localAuthorized,
409
- structure_ok,
410
- grant_supplied,
411
- binding_ok,
412
- signature_ok,
413
- signature_checked,
414
- coverage_ok,
415
- existence_checked: false,
416
- existence_ok: false,
417
- revocation_checked: false,
418
- not_revoked: false,
419
- reasons,
420
- }
421
-
422
- // ── 5. RESOLVER (live revocation + existence) — resolver-dependent ──────────
423
- if (input.resolver && auth?.grant_oid) {
424
- const grantOid = auth.grant_oid
425
- return Promise.resolve(input.resolver.resolveGrantStatus(grantOid)).then(
426
- (status): VerifyAuthorityResult => {
427
- const existence_ok = status.exists === true
428
- const not_revoked = existence_ok && status.revoked !== true
429
- if (!existence_ok) base.reasons.push('resolver: grant does not exist')
430
- if (existence_ok && status.revoked === true) {
431
- base.reasons.push('resolver: grant has been revoked')
432
- }
433
- return {
434
- ...base,
435
- existence_checked: true,
436
- existence_ok,
437
- revocation_checked: true,
438
- not_revoked,
439
- authorized: localAuthorized && existence_ok && not_revoked,
440
- }
441
- },
442
- )
443
- }
444
-
445
- return base
446
- }
447
-
448
- // ── verifyDelegationChain (K2) — VERIFY-ONLY delegation-chain verifier ────────
449
-
450
- /**
451
- * Hard cap on delegation depth. Checked BEFORE any hashing or signature work,
452
- * so an over-long chain is a cheap rejection (DoS guard) and never triggers
453
- * crypto. A chain of `links.length > MAX_DELEGATION_DEPTH` fails closed.
454
- */
455
- export const MAX_DELEGATION_DEPTH = 8
456
-
457
- /** The hybrid verifier public keys for one link's issuer. */
458
- export interface LinkPubkeys {
459
- /** Raw 32-byte Ed25519 public key. */
460
- ed25519: Uint8Array
461
- /** Raw ML-DSA-65 public key bytes. */
462
- ml_dsa: Uint8Array
463
- }
464
-
465
- /**
466
- * Per-hop result. Index `i` describes child `links[i]` verified UNDER parent
467
- * `links[i+1]`. The terminal link (root) has no parent hop, so `per_hop` has
468
- * `depth - 1` entries.
469
- */
470
- export interface HopResult {
471
- /** Index of the child link in the leaf->root `links` array. */
472
- child_index: number
473
- /** child.body.granted_by === parent.body.grantee.actor_oid. */
474
- granted_by_ok: boolean
475
- /** Every child scope is covered by some parent scope (no widening). */
476
- attenuation_ok: boolean
477
- /** Child expiry does not widen the parent's (monotone narrowing). */
478
- expiry_ok: boolean
479
- }
480
-
481
- export interface VerifyDelegationChainInput {
482
- /** The leaf grant (most-attenuated, end of the chain). */
483
- leaf: CDRO<GrantBodyShape>
484
- /**
485
- * Ancestors, ordered leaf-adjacent -> root: the leaf's parent first, the
486
- * root grant last. The full chain is `links = [leaf, ...ancestors]`, so
487
- * `links[i]` is the child of `links[i+1]` and `links[links.length-1]` is the
488
- * terminal root grant.
489
- */
490
- ancestors: CDRO<GrantBodyShape>[]
491
- /**
492
- * Hybrid verifier keys, index-aligned to `links` (so `linkPubkeys[i]` is the
493
- * issuer key of `links[i]`). Caller-supplied: the verifier never fetches
494
- * keys. `linkPubkeys[links.length-1]` MUST equal `rootPubkeys`.
495
- */
496
- linkPubkeys: LinkPubkeys[]
497
- /**
498
- * The trusted root principal's keys. The terminal link's signer key MUST
499
- * deep-equal these, else the chain does not anchor to a known root.
500
- */
501
- rootPubkeys: LinkPubkeys
502
- /**
503
- * Pin the DSSE payloadType for every link. Defaults to the SRAID grant
504
- * media type.
505
- */
506
- attestationPayloadType?: string
507
- /**
508
- * Optional requested action. When supplied, the leaf grant's capability
509
- * scopes MUST cover this action (GATE 5). If uncovered, `authorized` is
510
- * false and `action_ok` is false. When omitted, GATE 5 is skipped and
511
- * `action_checked` is false (vacuous pass — existing callers unaffected).
512
- */
513
- action?: string
514
- }
515
-
516
- /**
517
- * Result of `verifyDelegationChain`. Mirrors `VerifyAuthorityResult`'s
518
- * honesty discipline: the three live-claim fields are LITERAL-TYPED `false`
519
- * so the type system itself forbids a future edit from quietly asserting a
520
- * resolver-backed claim from this offline, verify-only function.
521
- */
522
- export interface VerifyDelegationChainResult {
523
- /** True iff every hop check, every link signature, and the root anchor passed. */
524
- authorized: boolean
525
- /** Number of grants in the chain (`links.length`). */
526
- depth: number
527
- /** depth >= 1 && depth <= MAX_DELEGATION_DEPTH. Checked before any crypto. */
528
- depth_ok: boolean
529
- /** Every child.granted_by === parent.grantee.actor_oid. */
530
- links_ok: boolean
531
- /** Every child scope covered by some parent scope, all hops (no widening). */
532
- attenuation_ok: boolean
533
- /** Monotone expiry narrowing, all hops. */
534
- expiry_ok: boolean
535
- /** Every link carried a valid hybrid DSSE attestation over its content core. */
536
- signatures_ok: boolean
537
- /** False if any link lacked an attestation, or if no crypto ran (over-depth). */
538
- signatures_checked: boolean
539
- /** Every link OID is hash-honest (recomputed === claimed). */
540
- oids_ok: boolean
541
- /** Terminal link signer key == rootPubkeys. */
542
- root_ok: boolean
543
- /**
544
- * True iff the requested action is covered by some leaf scope.
545
- * When `action` was not supplied, this is true (vacuous pass).
546
- */
547
- action_ok: boolean
548
- /**
549
- * True when an `action` was supplied and GATE 5 ran; false when the caller
550
- * omitted `action` (GATE 5 skipped).
551
- */
552
- action_checked: boolean
553
- /** ALWAYS false — verify-only, no live revocation claim (literal type). */
554
- revocation_checked: false
555
- /** ALWAYS false — verify-only (literal type). */
556
- not_revoked: false
557
- /** ALWAYS false — no resolver, no existence claim (literal type). */
558
- existence_checked: false
559
- /** Per-hop breakdown; index i = child links[i] under parent links[i+1]. */
560
- per_hop: HopResult[]
561
- /** Human-readable failure reasons. Empty when fully authorized. */
562
- reasons: string[]
563
- }
564
-
565
- function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
566
- if (a.length !== b.length) return false
567
- let diff = 0
568
- for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number)
569
- return diff === 0
570
- }
571
-
572
- /** Extract string capabilities from a grant body's scope array. */
573
- function scopeCaps(grant: CDRO<GrantBodyShape> | undefined): string[] {
574
- const scopes = grant?.body?.capability_scopes
575
- if (!Array.isArray(scopes)) return []
576
- const out: string[] = []
577
- for (const s of scopes) {
578
- if (s && typeof s.capability === 'string') out.push(s.capability)
579
- }
580
- return out
581
- }
582
-
583
- /**
584
- * Verify a delegation chain OFFLINE (K2). VERIFY-ONLY: there is no enforcement
585
- * wiring and no resolver — the caller supplies the ordered ancestors and the
586
- * per-link verifier keys, and this function makes NO live claim. It ALWAYS
587
- * returns `revocation_checked: false` and `existence_checked: false`; those
588
- * remain RESOLVER-DEPENDENT (SRAID_FOUNDATION_PUNCHLIST C) and are explicitly
589
- * out of scope here. The function is synchronous (no Promise overload), which
590
- * is what makes "no live claim" structural rather than merely documented.
591
- *
592
- * The gate runs in this fixed order; DEPTH is checked BEFORE any crypto:
593
- *
594
- * GATE 0 DEPTH CAP — depth in [1, MAX_DELEGATION_DEPTH]; else return with
595
- * NO hashing or signature work (cheap DoS guard).
596
- * GATE 1 OID HONESTY — every link's recomputed content-core OID equals its
597
- * claimed `oid` (rejects a body swapped under a fixed
598
- * OID reference).
599
- * GATE 2 PER-HOP — for each child links[i] under parent links[i+1]:
600
- * (a) granted_by linkage, (b) scope attenuation (every
601
- * child scope covered by some parent scope; empty
602
- * child scopes = FAIL — a grant of nothing is not
603
- * vacuously attenuated), (c) expiry monotone narrowing
604
- * (null parent = unbounded; null child under a bounded
605
- * parent = widening = FAIL).
606
- * GATE 3 SIGNATURES — every link carries a hybrid DSSE attestation
607
- * (ed25519 AND ml-dsa-65) over PAE(payloadType,
608
- * payload), and the attestation payload equals
609
- * canonicalize(cdroContentCore(link)) (payload-swap
610
- * guard). A missing attestation sets signatures_checked
611
- * false for that link and fails.
612
- * GATE 4 ROOT ANCHOR — the terminal link's issuer key deep-equals
613
- * rootPubkeys.
614
- *
615
- * `authorized` is the AND of every gate.
616
- */
617
- export function verifyDelegationChain(
618
- input: VerifyDelegationChainInput,
619
- ): VerifyDelegationChainResult {
620
- const reasons: string[] = []
621
- const links = [input.leaf, ...input.ancestors]
622
- const depth = links.length
623
- const expectedPayloadType = input.attestationPayloadType ?? DEFAULT_GRANT_PAYLOAD_TYPE
624
-
625
- const fail = (
626
- over: Partial<VerifyDelegationChainResult>,
627
- ): VerifyDelegationChainResult => ({
628
- authorized: false,
629
- depth,
630
- depth_ok: false,
631
- links_ok: false,
632
- attenuation_ok: false,
633
- expiry_ok: false,
634
- signatures_ok: false,
635
- signatures_checked: false,
636
- oids_ok: false,
637
- root_ok: false,
638
- action_ok: false,
639
- action_checked: false,
640
- revocation_checked: false,
641
- not_revoked: false,
642
- existence_checked: false,
643
- per_hop: [],
644
- reasons,
645
- ...over,
646
- })
647
-
648
- // ── GATE 0 — DEPTH CAP (before any hashing or signature work) ──────────────
649
- const depth_ok = depth >= 1 && depth <= MAX_DELEGATION_DEPTH
650
- if (!depth_ok) {
651
- if (depth < 1) reasons.push('empty chain — no leaf grant supplied')
652
- else reasons.push(`chain depth ${depth} exceeds cap ${MAX_DELEGATION_DEPTH}`)
653
- // RETURN with no crypto run: signatures_checked stays false.
654
- return fail({ depth_ok: false })
655
- }
656
-
657
- if (input.linkPubkeys.length !== depth) {
658
- reasons.push(
659
- `linkPubkeys length ${input.linkPubkeys.length} does not match chain depth ${depth}`,
660
- )
661
- return fail({ depth_ok })
662
- }
663
-
664
- // ── GATE 1 — OID HASH-HONESTY, every link ──────────────────────────────────
665
- let oids_ok = true
666
- for (let i = 0; i < depth; i++) {
667
- const link = links[i] as CDRO<GrantBodyShape>
668
- let recomputed: string | null = null
669
- try {
670
- recomputed = cdroOid(link)
671
- } catch {
672
- recomputed = null
673
- }
674
- if (recomputed === null || recomputed !== link.oid) {
675
- oids_ok = false
676
- reasons.push(`link ${i} OID does not match its content (tampered grant body)`)
677
- }
678
- }
679
-
680
- // ── GATE 2 — PER-HOP STRUCTURE (child links[i] under parent links[i+1]) ─────
681
- let links_ok = true
682
- let attenuation_ok = true
683
- let expiry_ok = true
684
- const per_hop: HopResult[] = []
685
-
686
- for (let i = 0; i < depth - 1; i++) {
687
- const child = links[i] as CDRO<GrantBodyShape>
688
- const parent = links[i + 1] as CDRO<GrantBodyShape>
689
-
690
- // (a) GRANTED_BY linkage
691
- const childGrantedBy = child.body?.granted_by
692
- const parentGrantee = parent.body?.grantee?.actor_oid
693
- const granted_by_ok =
694
- typeof childGrantedBy === 'string' &&
695
- typeof parentGrantee === 'string' &&
696
- childGrantedBy === parentGrantee
697
- if (!granted_by_ok) {
698
- links_ok = false
699
- reasons.push(
700
- `hop ${i}: child.granted_by does not equal parent.grantee.actor_oid (broken signer link)`,
701
- )
702
- }
703
-
704
- // (b) ATTENUATION — every child scope covered by some parent scope.
705
- const childCaps = scopeCaps(child)
706
- const parentCaps = scopeCaps(parent)
707
- let hopAtten = true
708
- if (childCaps.length === 0) {
709
- // A grant that grants nothing is REJECTED (conservative default), not
710
- // treated as vacuously attenuated. (Flagged for founder review.)
711
- hopAtten = false
712
- reasons.push(`hop ${i}: child grant has no capability scopes (rejected)`)
713
- } else {
714
- for (const c of childCaps) {
715
- const covered = parentCaps.some((p) => capabilityCovers(p, c))
716
- if (!covered) {
717
- hopAtten = false
718
- reasons.push(`hop ${i}: child scope "${c}" widens parent (not attenuated)`)
719
- }
720
- }
721
- }
722
- if (!hopAtten) attenuation_ok = false
723
-
724
- // (c) EXPIRY MONOTONE-NARROWING
725
- const pe = parent.body?.expires_at_ms
726
- const ce = child.body?.expires_at_ms
727
- let hopExpiry = true
728
- if (pe === null || pe === undefined) {
729
- // unbounded parent: any child OK
730
- hopExpiry = true
731
- } else if (typeof pe === 'number') {
732
- if (ce === null || ce === undefined) {
733
- hopExpiry = false
734
- reasons.push(`hop ${i}: child expiry is unbounded under a bounded parent (widening)`)
735
- } else if (typeof ce === 'number') {
736
- if (ce > pe) {
737
- hopExpiry = false
738
- reasons.push(`hop ${i}: child expiry ${ce} is later than parent ${pe} (widening)`)
739
- }
740
- }
741
- }
742
- if (!hopExpiry) expiry_ok = false
743
-
744
- per_hop.push({
745
- child_index: i,
746
- granted_by_ok,
747
- attenuation_ok: hopAtten,
748
- expiry_ok: hopExpiry,
749
- })
750
- }
751
-
752
- // ── GATE 3 — HYBRID DSSE SIGNATURE, every link ─────────────────────────────
753
- let signatures_ok = true
754
- let signatures_checked = true
755
- for (let i = 0; i < depth; i++) {
756
- const link = links[i] as CDRO<GrantBodyShape>
757
- const att = link.attestation
758
- if (!att) {
759
- signatures_checked = false
760
- signatures_ok = false
761
- reasons.push(`link ${i} carries no DSSE attestation`)
762
- continue
763
- }
764
- // payload-swap guard: the signed payload MUST equal this link's content core.
765
- let expectedPayload: string | null = null
766
- try {
767
- expectedPayload = canonicalize(cdroContentCore(link))
768
- } catch {
769
- expectedPayload = null
770
- }
771
- if (expectedPayload === null || att.payload !== expectedPayload) {
772
- signatures_ok = false
773
- reasons.push(`link ${i} attestation payload does not match its content core (payload swap)`)
774
- continue
775
- }
776
- const keys = input.linkPubkeys[i] as LinkPubkeys
777
- const r = verifyAttestation({
778
- envelope: att,
779
- ed25519_pub: keys.ed25519,
780
- ml_dsa_pub: keys.ml_dsa,
781
- expectedPayloadType,
782
- })
783
- if (!r.valid) {
784
- signatures_ok = false
785
- reasons.push(`link ${i} hybrid signature invalid: ${r.reasons.join(',')}`)
786
- }
787
- }
788
-
789
- // ── GATE 4 — ROOT ANCHOR ───────────────────────────────────────────────────
790
- const terminalKeys = input.linkPubkeys[depth - 1] as LinkPubkeys
791
- const root_ok =
792
- bytesEqual(terminalKeys.ed25519, input.rootPubkeys.ed25519) &&
793
- bytesEqual(terminalKeys.ml_dsa, input.rootPubkeys.ml_dsa)
794
- if (!root_ok) {
795
- reasons.push('terminal link signer key does not equal rootPubkeys (forged/unknown root)')
796
- }
797
-
798
- // ── GATE 5 — ACTION COVERAGE (optional) ────────────────────────────────────
799
- // When the caller supplies `action`, the leaf's capability scopes MUST cover
800
- // it. Leaf = links[0]. Attenuation (GATE 2) already proved every ancestor
801
- // covers the leaf, so leaf coverage + attenuation transitively proves the
802
- // whole chain covers the action. Checking every hop would be redundant.
803
- let action_ok = true
804
- let action_checked = false
805
- if (input.action !== undefined) {
806
- action_checked = true
807
- const leafCaps = scopeCaps(input.leaf)
808
- const covered = leafCaps.some((s) => capabilityCovers(s, input.action as string))
809
- if (!covered) {
810
- action_ok = false
811
- reasons.push(
812
- `requested action "${input.action}" not covered by leaf grant`,
813
- )
814
- }
815
- }
816
-
817
- const authorized =
818
- depth_ok &&
819
- oids_ok &&
820
- links_ok &&
821
- attenuation_ok &&
822
- expiry_ok &&
823
- signatures_ok &&
824
- signatures_checked &&
825
- root_ok &&
826
- action_ok
827
-
828
- return {
829
- authorized,
830
- depth,
831
- depth_ok,
832
- links_ok,
833
- attenuation_ok,
834
- expiry_ok,
835
- signatures_ok,
836
- signatures_checked,
837
- oids_ok,
838
- root_ok,
839
- action_ok,
840
- action_checked,
841
- revocation_checked: false,
842
- not_revoked: false,
843
- existence_checked: false,
844
- per_hop,
845
- reasons,
846
- }
847
- }
848
-
849
- import { canonicalize } from './canonicalize.js'