@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/oid.ts
CHANGED
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
24
|
import { canonicalize } from './canonicalize.js'
|
|
25
|
+
import { CDRO_ENVELOPE_FIELDS, cdroContentCore } from './internal/content-core.js'
|
|
26
|
+
|
|
27
|
+
// The CDRO strip-set and its content-core projection are the SINGLE NORMATIVE
|
|
28
|
+
// SOURCE (ADR_019) and live in the PURE ./internal/content-core module (no
|
|
29
|
+
// node:crypto), so the browser verify surface can share them byte-for-byte.
|
|
30
|
+
// oid.ts re-exports them and layers the SHA-256 hashing (below) on top; this
|
|
31
|
+
// keeps the public surface (`import { cdroContentCore, CDRO_ENVELOPE_FIELDS }
|
|
32
|
+
// from '@synoi/sraid'`) unchanged. See ./internal/content-core.ts for the
|
|
33
|
+
// normative strip-set documentation; do not re-list it here.
|
|
34
|
+
export { CDRO_ENVELOPE_FIELDS, cdroContentCore }
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
37
|
* Compute an OID over an arbitrary canonical-compatible value.
|
|
@@ -53,79 +63,6 @@ export function oidOfCanonical(canonical: string | Uint8Array): string {
|
|
|
53
63
|
return 'sha256:' + createHash('sha256').update(bytes).digest('hex')
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
/**
|
|
57
|
-
* The detached-signature / envelope fields removed by `cdroContentCore`
|
|
58
|
-
* before hashing. This is the SINGLE NORMATIVE strip-set for the CDRO OID
|
|
59
|
-
* projection (ADR_019 decision 1); every other surface (GAP SDKs, the
|
|
60
|
-
* gateway signer, IMPLEMENTING.md) derives from THIS set, never re-lists it.
|
|
61
|
-
*
|
|
62
|
-
* The set is defined SEMANTICALLY: it is "every field produced BY the signer
|
|
63
|
-
* after canonicalization, plus the OID output itself." Concretely:
|
|
64
|
-
*
|
|
65
|
-
* oid — the projection OUTPUT (cannot be an input to itself).
|
|
66
|
-
* signature — legacy hybrid SignatureEnvelope (attaches after hash).
|
|
67
|
-
* ml_dsa_signature — detached PQ signature (attaches after hash).
|
|
68
|
-
* signature_key_id — signer-stamped key id (produced by the signer).
|
|
69
|
-
* signature_algorithm — signer-stamped alg id (produced by the signer).
|
|
70
|
-
* attestation — DSSE AttestationEnvelope (attaches after hash).
|
|
71
|
-
*
|
|
72
|
-
* EVERYTHING ELSE IS KEPT and hashed into the OID, including in particular:
|
|
73
|
-
* - `gap_version` — IN identity so a protocol downgrade is OID-detectable.
|
|
74
|
-
* - `supersedes` — IN identity because the SRAID Merkle-DAG head-proves-
|
|
75
|
-
* history property requires every lineage edge inside the
|
|
76
|
-
* hash. Superseding mints a NEW object; it never mutates
|
|
77
|
-
* the old one's bytes, so keeping it here is safe and
|
|
78
|
-
* makes the lineage edge tamper-evident.
|
|
79
|
-
* - `type`, `sraid_version`, `tenant_id`, `created_at_ms`, `created_by`,
|
|
80
|
-
* `body`, `authority`, `sensitivity`, `prev`, `links`, and any other
|
|
81
|
-
* content field.
|
|
82
|
-
*
|
|
83
|
-
* This is the ONE projection that yields the SAME OID whether the object is
|
|
84
|
-
* pre- or post-attestation: attaching an `attestation` (or `signature`,
|
|
85
|
-
* `ml_dsa_signature`, `signature_key_id`, `signature_algorithm`) after hashing
|
|
86
|
-
* is stripped back out here, so `cdroOid(obj)` is invariant across signing.
|
|
87
|
-
*
|
|
88
|
-
* It is FROZEN so no caller can mutate the normative set at runtime.
|
|
89
|
-
*/
|
|
90
|
-
export const CDRO_ENVELOPE_FIELDS: readonly string[] = Object.freeze([
|
|
91
|
-
'oid',
|
|
92
|
-
'signature',
|
|
93
|
-
'ml_dsa_signature',
|
|
94
|
-
'signature_key_id',
|
|
95
|
-
'signature_algorithm',
|
|
96
|
-
'attestation',
|
|
97
|
-
])
|
|
98
|
-
|
|
99
|
-
const CDRO_ENVELOPE_FIELD_SET: ReadonlySet<string> = new Set(CDRO_ENVELOPE_FIELDS)
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Build the OID content core of a full CDRO: the object with EXACTLY the six
|
|
103
|
-
* detached-signature / envelope fields in `CDRO_ENVELOPE_FIELDS` removed at
|
|
104
|
-
* the top level, and everything else kept.
|
|
105
|
-
*
|
|
106
|
-
* This is the mechanism that makes the L4 `authority` block, the L3 lineage
|
|
107
|
-
* edges (`prev`, `links`, `supersedes`), the propagating `sensitivity` tier,
|
|
108
|
-
* and `gap_version` tamper-evident: they are hashed into identity by
|
|
109
|
-
* construction, so a field cannot be added, stripped, re-pointed, or
|
|
110
|
-
* downgraded without producing a different OID (and invalidating the
|
|
111
|
-
* signature, which is computed over these same bytes). Because `prev`/`links`/
|
|
112
|
-
* `supersedes` OIDs are inside the hash, a node's OID transitively commits its
|
|
113
|
-
* whole reachable history (the Merkle-DAG "head proves history" property).
|
|
114
|
-
*
|
|
115
|
-
* Returns a plain object suitable for `canonicalize` / `oidOf`.
|
|
116
|
-
*/
|
|
117
|
-
export function cdroContentCore(cdro: unknown): Record<string, unknown> {
|
|
118
|
-
if (cdro === null || typeof cdro !== 'object' || Array.isArray(cdro)) {
|
|
119
|
-
throw new TypeError('cdroContentCore: argument must be a CDRO object')
|
|
120
|
-
}
|
|
121
|
-
const core: Record<string, unknown> = {}
|
|
122
|
-
for (const [k, v] of Object.entries(cdro as Record<string, unknown>)) {
|
|
123
|
-
if (CDRO_ENVELOPE_FIELD_SET.has(k)) continue
|
|
124
|
-
core[k] = v
|
|
125
|
-
}
|
|
126
|
-
return core
|
|
127
|
-
}
|
|
128
|
-
|
|
129
66
|
/**
|
|
130
67
|
* Compute the OID of a full CDRO over its content core (see
|
|
131
68
|
* `cdroContentCore`). This is the correct way to derive identity for a
|
package/src/types.ts
CHANGED
|
@@ -233,14 +233,15 @@ export interface AttestationEnvelope {
|
|
|
233
233
|
* CDRO — Canonical Data Record Object.
|
|
234
234
|
*
|
|
235
235
|
* The base shape every signed CDRO object takes. The `oid` field is
|
|
236
|
-
* `sha256:` + hex(sha256(canonicalize(
|
|
237
|
-
* minus the
|
|
236
|
+
* `sha256:` + hex(sha256(canonicalize(cdroContentCore(obj)))) — the WHOLE
|
|
237
|
+
* object minus the six detached envelope fields (`CDRO_ENVELOPE_FIELDS`).
|
|
238
|
+
* It is NOT a hash of `body` alone: use `cdroOid`, not `oidOf(cdro.body)`.
|
|
238
239
|
*
|
|
239
240
|
* Mirrors `GapCdroEnvelope` in synoi-gateway so a GAP object IS a CDRO.
|
|
240
241
|
* Higher-layer packages narrow `body` with their own type.
|
|
241
242
|
*/
|
|
242
243
|
export interface CDRO<TBody = unknown> {
|
|
243
|
-
/** "sha256:" + hex(sha256(canonicalize(
|
|
244
|
+
/** "sha256:" + hex(sha256(canonicalize(cdroContentCore(this)))). */
|
|
244
245
|
oid: string
|
|
245
246
|
/** Object type discriminator (e.g. "gap:capability_grant"). */
|
|
246
247
|
type: string
|
|
@@ -321,31 +322,22 @@ export interface CDRO<TBody = unknown> {
|
|
|
321
322
|
signature?: SignatureEnvelope
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
// ──
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
reason: string
|
|
344
|
-
/** OID of the actor that authorized this mutation. */
|
|
345
|
-
authorized_by: string
|
|
346
|
-
/** Optional list of supporting evidence OIDs (audit trail). */
|
|
347
|
-
evidence_oids?: string[]
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** SRO is just a CDRO with a fixed body type. */
|
|
351
|
-
export type SRO = CDRO<SROBody> & { type: 'sraid:sro' }
|
|
325
|
+
// ── Supersession ──────────────────────────────────────────────────────────────
|
|
326
|
+
//
|
|
327
|
+
// The standalone SRO (Supersession Record Object, `type: 'sraid:sro'`) was
|
|
328
|
+
// REMOVED in 0.4.0. It was the third of three overlapping supersession
|
|
329
|
+
// mechanisms and the only one nothing consumed: `lineage.ts` never handled it,
|
|
330
|
+
// no repo in the SynOI stack referenced it in any language, and the canon had
|
|
331
|
+
// already retired the term (ADR_022 section 0: "an earlier, retired
|
|
332
|
+
// object-format term").
|
|
333
|
+
//
|
|
334
|
+
// Supersession is now expressed by exactly two things, both identity-bound
|
|
335
|
+
// because `cdroContentCore` hashes them:
|
|
336
|
+
//
|
|
337
|
+
// prev / links[] the Merkle edges - a head OID commits its whole reachable
|
|
338
|
+
// history. Resolve a version set with `latestWins`.
|
|
339
|
+
// supersedes the legacy self-asserted string, kept for compatibility.
|
|
340
|
+
//
|
|
341
|
+
// A caller that needs to record WHY something was superseded should put that
|
|
342
|
+
// in the successor's own `body`, where it is hashed and signed, rather than in
|
|
343
|
+
// a separate object whose linkage was never verified.
|
package/src/validate.ts
CHANGED
|
@@ -20,7 +20,6 @@ import type {
|
|
|
20
20
|
LineageLink,
|
|
21
21
|
LinkRel,
|
|
22
22
|
SignatureEnvelope,
|
|
23
|
-
SRO,
|
|
24
23
|
} from './types.js'
|
|
25
24
|
import { SENSITIVITY_TIERS, isSensitivityTier } from './sensitivity.js'
|
|
26
25
|
|
|
@@ -327,68 +326,7 @@ function validateAttestationEnvelopeShape(x: unknown): string[] {
|
|
|
327
326
|
return errors
|
|
328
327
|
}
|
|
329
328
|
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Validate that `x` matches the SRO shape — a CDRO whose `type` is
|
|
334
|
-
* "sraid:sro" and whose `body` carries predecessor/successor pointers and
|
|
335
|
-
* an authorizer. Reuses validateCdro and layers SRO-specific checks on
|
|
336
|
-
* top.
|
|
337
|
-
*
|
|
338
|
-
* [S01] envelope is not a valid CDRO
|
|
339
|
-
* [S02] type is not "sraid:sro"
|
|
340
|
-
* [S03] body.predecessor_oid not a canonical OID (sha256:<64 hex>)
|
|
341
|
-
* [S04] body.successor_oid not a canonical OID (sha256:<64 hex>)
|
|
342
|
-
* [S05] body.reason not a non-empty string
|
|
343
|
-
* [S06] body.authorized_by not a non-empty string
|
|
344
|
-
* [S07] body.evidence_oids, if present, not an array of non-empty strings
|
|
345
|
-
*/
|
|
346
|
-
export function validateSro(x: unknown): ValidationResult {
|
|
347
|
-
const cdroResult = validateCdro(x)
|
|
348
|
-
if (!cdroResult.ok) {
|
|
349
|
-
return { ok: false, errors: cdroResult.errors.map((e) => '[S01] ' + e) }
|
|
350
|
-
}
|
|
351
|
-
const errors: string[] = []
|
|
352
|
-
const o = x as { type?: unknown; body?: unknown }
|
|
353
|
-
|
|
354
|
-
if (o.type !== 'sraid:sro') {
|
|
355
|
-
errors.push('[S02] type must be "sraid:sro" for an SRO')
|
|
356
|
-
}
|
|
357
|
-
if (o.body === null || typeof o.body !== 'object' || Array.isArray(o.body)) {
|
|
358
|
-
errors.push('[S01] body must be an object')
|
|
359
|
-
return { ok: false, errors }
|
|
360
|
-
}
|
|
361
|
-
const b = o.body as Record<string, unknown>
|
|
362
|
-
|
|
363
|
-
if (!isCanonicalOid(b['predecessor_oid'])) {
|
|
364
|
-
errors.push('[S03] body.predecessor_oid must be a canonical OID (sha256:<64 hex>)')
|
|
365
|
-
}
|
|
366
|
-
if (!isCanonicalOid(b['successor_oid'])) {
|
|
367
|
-
errors.push('[S04] body.successor_oid must be a canonical OID (sha256:<64 hex>)')
|
|
368
|
-
}
|
|
369
|
-
if (typeof b['reason'] !== 'string' || (b['reason'] as string).length === 0) {
|
|
370
|
-
errors.push('[S05] body.reason must be a non-empty string')
|
|
371
|
-
}
|
|
372
|
-
if (typeof b['authorized_by'] !== 'string' || (b['authorized_by'] as string).length === 0) {
|
|
373
|
-
errors.push('[S06] body.authorized_by must be a non-empty string')
|
|
374
|
-
}
|
|
375
|
-
if (b['evidence_oids'] !== undefined) {
|
|
376
|
-
if (!Array.isArray(b['evidence_oids'])) {
|
|
377
|
-
errors.push('[S07] body.evidence_oids, if present, must be an array')
|
|
378
|
-
} else {
|
|
379
|
-
const arr = b['evidence_oids'] as unknown[]
|
|
380
|
-
for (let i = 0; i < arr.length; i++) {
|
|
381
|
-
if (typeof arr[i] !== 'string' || (arr[i] as string).length === 0) {
|
|
382
|
-
errors.push(`[S07] body.evidence_oids[${i}] must be a non-empty string`)
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return { ok: errors.length === 0, errors }
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Type-only re-exports so consumers can do `import type { CDRO, SRO } from '@synoi/sraid'`
|
|
329
|
+
// Type-only re-exports so consumers can do `import type { CDRO } from '@synoi/sraid'`
|
|
392
330
|
// after pulling validators from this module.
|
|
393
331
|
export type {
|
|
394
332
|
AttestationEnvelope,
|
|
@@ -397,6 +335,5 @@ export type {
|
|
|
397
335
|
CDRO,
|
|
398
336
|
LineageLink,
|
|
399
337
|
LinkRel,
|
|
400
|
-
SRO,
|
|
401
338
|
SignatureEnvelope,
|
|
402
339
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid/verify-browser — browser / Chrome-extension / service-worker
|
|
3
|
+
* verify surface.
|
|
4
|
+
*
|
|
5
|
+
* The default (`.`) entry statically imports `node:crypto` in three places:
|
|
6
|
+
* ed25519.ts (node-only Ed25519 verify), mldsa.ts (native ML-DSA + polyfill),
|
|
7
|
+
* and oid.ts (createHash). `node:crypto` does not exist in a browser or
|
|
8
|
+
* service-worker context, so `import '@synoi/sraid'` breaks any browser bundle.
|
|
9
|
+
* This entry is the browser-safe alternative: it carries exactly what a v2
|
|
10
|
+
* hybrid DSSE receipt verifier needs, implemented WITHOUT any static
|
|
11
|
+
* node:crypto import.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THIS EXPOSES (all browser-safe):
|
|
14
|
+
* - canonicalize — pure RFC 8785 JCS serializer (shared, unchanged).
|
|
15
|
+
* - cdroContentCore, — the CDRO OID content-core projection and its
|
|
16
|
+
* CDRO_ENVELOPE_FIELDS normative strip-set (pure; shared byte-for-byte
|
|
17
|
+
* with the node entry via internal/content-core).
|
|
18
|
+
* - verifyAttestation — hybrid DSSE verify (Ed25519 AND ML-DSA-65, both
|
|
19
|
+
* required over the PAE). ASYNC here: WebCrypto
|
|
20
|
+
* Ed25519 verify is Promise-based.
|
|
21
|
+
* - pae, ALG_ED25519, — the DSSE PAE encoder and algorithm ids (pure,
|
|
22
|
+
* ALG_ML_DSA_65 shared with the node entry).
|
|
23
|
+
* - cdroOid, oidOf, — OID helpers over WebCrypto SHA-256. ASYNC here
|
|
24
|
+
* oidOfCanonical (subtle.digest is Promise-based).
|
|
25
|
+
*
|
|
26
|
+
* CRYPTO BACKENDS (browsers have no native ML-DSA and no node:crypto):
|
|
27
|
+
* - Ed25519 verify → WebCrypto (RFC 8032 cofactored, matching the node path),
|
|
28
|
+
* with a @noble/curves fallback below the WebCrypto-Ed25519
|
|
29
|
+
* support floor. See internal/ed25519-browser.ts.
|
|
30
|
+
* - ML-DSA-65 verify → @noble/post-quantum (pure JS). See internal/mldsa-browser.ts.
|
|
31
|
+
* - SHA-256 → WebCrypto subtle.digest. See internal/sha256-browser.ts.
|
|
32
|
+
*
|
|
33
|
+
* NODE PARITY: the node default entry (`@synoi/sraid`) is unchanged and stays
|
|
34
|
+
* synchronous with its node:crypto fast paths. This entry is purely additive.
|
|
35
|
+
* The only surface difference is async: verifyAttestation, cdroOid, oidOf, and
|
|
36
|
+
* oidOfCanonical return Promises here because WebCrypto is Promise-based.
|
|
37
|
+
*
|
|
38
|
+
* Downstream: @synoi/verify's browser build imports this subpath to bring v2
|
|
39
|
+
* hybrid DSSE verification (verifyReceiptV2) to the browser, replacing the
|
|
40
|
+
* fail-closed `v2-not-supported-in-browser-build` stub.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { canonicalize } from './canonicalize.js'
|
|
44
|
+
import { CDRO_ENVELOPE_FIELDS, cdroContentCore } from './internal/content-core.js'
|
|
45
|
+
import { decodeBase64StrictBrowser } from './internal/base64-browser.js'
|
|
46
|
+
import { verifyEd25519Browser } from './internal/ed25519-browser.js'
|
|
47
|
+
import { verifyMlDsa65Browser } from './internal/mldsa-browser.js'
|
|
48
|
+
import { sha256HexPrefixed } from './internal/sha256-browser.js'
|
|
49
|
+
import {
|
|
50
|
+
ALG_ED25519,
|
|
51
|
+
ALG_ML_DSA_65,
|
|
52
|
+
findSig,
|
|
53
|
+
isWellFormedEnvelope,
|
|
54
|
+
pae,
|
|
55
|
+
type VerifyAttestationInput,
|
|
56
|
+
type VerifyAttestationResult,
|
|
57
|
+
} from './internal/attestation-core.js'
|
|
58
|
+
|
|
59
|
+
// ── Pure / shared re-exports (byte-for-byte identical to the node entry) ──────
|
|
60
|
+
|
|
61
|
+
export { canonicalize } from './canonicalize.js'
|
|
62
|
+
export { CDRO_ENVELOPE_FIELDS, cdroContentCore } from './internal/content-core.js'
|
|
63
|
+
export {
|
|
64
|
+
pae,
|
|
65
|
+
ALG_ED25519,
|
|
66
|
+
ALG_ML_DSA_65,
|
|
67
|
+
type VerifyAttestationInput,
|
|
68
|
+
type VerifyAttestationResult,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Hybrid DSSE attestation verify (browser, ASYNC) ───────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify a hybrid DSSE attestation envelope in a browser context. Returns
|
|
75
|
+
* `valid: true` only when the envelope carries BOTH an `ed25519` and an
|
|
76
|
+
* `ml-dsa-65` signature and BOTH verify against the supplied public keys over
|
|
77
|
+
* `PAE(payloadType, payload)`. The payloadType is bound into the signed bytes,
|
|
78
|
+
* so a signature minted for a different payloadType will not verify.
|
|
79
|
+
*
|
|
80
|
+
* ASYNC counterpart of the node `verifyAttestation`: identical envelope shape,
|
|
81
|
+
* AND policy, PAE bytes, and reason strings — only the return is a Promise,
|
|
82
|
+
* because Ed25519 verify runs on WebCrypto. Never rejects: any malformed input
|
|
83
|
+
* or verification failure resolves to `{ valid: false, reasons: [...] }`.
|
|
84
|
+
*/
|
|
85
|
+
export async function verifyAttestation(
|
|
86
|
+
input: VerifyAttestationInput,
|
|
87
|
+
): Promise<VerifyAttestationResult> {
|
|
88
|
+
const reasons: string[] = []
|
|
89
|
+
|
|
90
|
+
const env = input.envelope
|
|
91
|
+
if (!isWellFormedEnvelope(env)) {
|
|
92
|
+
return { valid: false, reasons: ['envelope-malformed'] }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
input.expectedPayloadType !== undefined &&
|
|
97
|
+
env.payloadType !== input.expectedPayloadType
|
|
98
|
+
) {
|
|
99
|
+
return { valid: false, reasons: ['payload-type-mismatch'] }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The signed bytes: PAE binds payloadType + payload together.
|
|
103
|
+
const message = pae(env.payloadType, env.payload)
|
|
104
|
+
|
|
105
|
+
// Find the required hybrid pair. The AND policy: both must be present.
|
|
106
|
+
const edEntry = findSig(env.signatures, ALG_ED25519)
|
|
107
|
+
const mlEntry = findSig(env.signatures, ALG_ML_DSA_65)
|
|
108
|
+
|
|
109
|
+
if (!edEntry) reasons.push('missing-ed25519')
|
|
110
|
+
if (!mlEntry) reasons.push('missing-ml-dsa-65')
|
|
111
|
+
|
|
112
|
+
let edOk = false
|
|
113
|
+
let mlOk = false
|
|
114
|
+
|
|
115
|
+
if (edEntry) {
|
|
116
|
+
let edSig: Uint8Array | null = null
|
|
117
|
+
try {
|
|
118
|
+
edSig = decodeBase64StrictBrowser(edEntry.sig)
|
|
119
|
+
} catch {
|
|
120
|
+
reasons.push('ed25519-malformed')
|
|
121
|
+
}
|
|
122
|
+
if (edSig) {
|
|
123
|
+
try {
|
|
124
|
+
edOk = await verifyEd25519Browser(edSig, message, input.ed25519_pub)
|
|
125
|
+
} catch {
|
|
126
|
+
edOk = false
|
|
127
|
+
}
|
|
128
|
+
if (!edOk) reasons.push('ed25519-invalid')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (mlEntry) {
|
|
133
|
+
let mlSig: Uint8Array | null = null
|
|
134
|
+
try {
|
|
135
|
+
mlSig = decodeBase64StrictBrowser(mlEntry.sig)
|
|
136
|
+
} catch {
|
|
137
|
+
reasons.push('ml-dsa-malformed')
|
|
138
|
+
}
|
|
139
|
+
if (mlSig) {
|
|
140
|
+
try {
|
|
141
|
+
mlOk = verifyMlDsa65Browser(mlSig, message, input.ml_dsa_pub)
|
|
142
|
+
} catch {
|
|
143
|
+
mlOk = false
|
|
144
|
+
}
|
|
145
|
+
if (!mlOk) reasons.push('ml-dsa-invalid')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { valid: edOk && mlOk && !!edEntry && !!mlEntry, reasons }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── OID helpers (browser, ASYNC via WebCrypto SHA-256) ────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compute an OID over an arbitrary canonical-compatible value using WebCrypto
|
|
156
|
+
* SHA-256. Returns `sha256:` followed by 64 lowercase hex characters.
|
|
157
|
+
*
|
|
158
|
+
* ASYNC counterpart of the node `oidOf`: same canonical bytes and same
|
|
159
|
+
* `sha256:`-prefixed output, but returns a Promise because subtle.digest is
|
|
160
|
+
* Promise-based. Byte-identical result to the node entry for the same input.
|
|
161
|
+
*/
|
|
162
|
+
export async function oidOf(canonical: unknown): Promise<string> {
|
|
163
|
+
const bytes = new TextEncoder().encode(canonicalize(canonical))
|
|
164
|
+
return sha256HexPrefixed(bytes)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compute an OID directly from already-canonicalized bytes using WebCrypto
|
|
169
|
+
* SHA-256. ASYNC counterpart of the node `oidOfCanonical`.
|
|
170
|
+
*/
|
|
171
|
+
export async function oidOfCanonical(canonical: string | Uint8Array): Promise<string> {
|
|
172
|
+
const bytes =
|
|
173
|
+
typeof canonical === 'string' ? new TextEncoder().encode(canonical) : canonical
|
|
174
|
+
return sha256HexPrefixed(bytes)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compute the OID of a full CDRO over its content core (see `cdroContentCore`),
|
|
179
|
+
* using WebCrypto SHA-256. ASYNC counterpart of the node `cdroOid`; yields the
|
|
180
|
+
* SAME OID whether the object is pre- or post-attestation, and the SAME value
|
|
181
|
+
* the node entry produces for the same object.
|
|
182
|
+
*/
|
|
183
|
+
export async function cdroOid(cdro: unknown): Promise<string> {
|
|
184
|
+
return oidOf(cdroContentCore(cdro))
|
|
185
|
+
}
|