@synoi/sraid 0.2.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/README.md +156 -50
- package/SPEC.md +2 -2
- package/dist/attestation.d.ts +8 -49
- package/dist/attestation.d.ts.map +1 -1
- package/dist/attestation.js +15 -46
- package/dist/attestation.js.map +1 -1
- package/dist/capability.d.ts +59 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +44 -0
- package/dist/capability.js.map +1 -0
- package/dist/index.d.ts +13 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/index.js.map +1 -1
- package/dist/internal/attestation-core.d.ts +85 -0
- package/dist/internal/attestation-core.d.ts.map +1 -0
- package/dist/internal/attestation-core.js +78 -0
- package/dist/internal/attestation-core.js.map +1 -0
- package/dist/internal/base64-browser.d.ts +31 -0
- package/dist/internal/base64-browser.d.ts.map +1 -0
- package/dist/internal/base64-browser.js +39 -0
- package/dist/internal/base64-browser.js.map +1 -0
- package/dist/internal/base64-validate.d.ts +19 -0
- package/dist/internal/base64-validate.d.ts.map +1 -0
- package/dist/internal/base64-validate.js +33 -0
- package/dist/internal/base64-validate.js.map +1 -0
- package/dist/internal/base64.d.ts +7 -5
- package/dist/internal/base64.d.ts.map +1 -1
- package/dist/internal/base64.js +7 -19
- package/dist/internal/base64.js.map +1 -1
- package/dist/internal/content-core.d.ts +68 -0
- package/dist/internal/content-core.d.ts.map +1 -0
- package/dist/internal/content-core.js +87 -0
- package/dist/internal/content-core.js.map +1 -0
- package/dist/internal/ed25519-browser.d.ts +33 -0
- package/dist/internal/ed25519-browser.d.ts.map +1 -0
- package/dist/internal/ed25519-browser.js +79 -0
- package/dist/internal/ed25519-browser.js.map +1 -0
- package/dist/internal/mldsa-browser.d.ts +27 -0
- package/dist/internal/mldsa-browser.d.ts.map +1 -0
- package/dist/internal/mldsa-browser.js +35 -0
- package/dist/internal/mldsa-browser.js.map +1 -0
- package/dist/internal/sha256-browser.d.ts +17 -0
- package/dist/internal/sha256-browser.d.ts.map +1 -0
- package/dist/internal/sha256-browser.js +27 -0
- package/dist/internal/sha256-browser.js.map +1 -0
- package/dist/lineage.js +0 -0
- package/dist/lineage.js.map +1 -1
- package/dist/oid.d.ts +2 -52
- package/dist/oid.d.ts.map +1 -1
- package/dist/oid.js +9 -71
- package/dist/oid.js.map +1 -1
- package/dist/types.d.ts +4 -30
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +2 -17
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +0 -57
- package/dist/validate.js.map +1 -1
- package/dist/verify-browser.d.ts +80 -0
- package/dist/verify-browser.d.ts.map +1 -0
- package/dist/verify-browser.js +157 -0
- package/dist/verify-browser.js.map +1 -0
- package/package.json +22 -3
- package/src/attestation.ts +29 -88
- package/src/capability.ts +69 -0
- package/src/index.ts +27 -36
- package/src/internal/attestation-core.ts +122 -0
- package/src/internal/base64-browser.ts +39 -0
- package/src/internal/base64-validate.ts +31 -0
- package/src/internal/base64.ts +7 -16
- package/src/internal/content-core.ts +88 -0
- package/src/internal/ed25519-browser.ts +85 -0
- package/src/internal/mldsa-browser.ts +39 -0
- package/src/internal/sha256-browser.ts +28 -0
- package/src/lineage.ts +0 -0
- package/src/oid.ts +10 -73
- package/src/types.ts +23 -31
- package/src/validate.ts +1 -64
- package/src/verify-browser.ts +185 -0
- package/dist/authority.d.ts +0 -351
- package/dist/authority.d.ts.map +0 -1
- package/dist/authority.js +0 -563
- package/dist/authority.js.map +0 -1
- package/src/authority.ts +0 -849
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'
|