@synoi/sraid 0.2.0 → 0.3.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.
Files changed (57) hide show
  1. package/README.md +51 -6
  2. package/dist/attestation.d.ts +8 -49
  3. package/dist/attestation.d.ts.map +1 -1
  4. package/dist/attestation.js +15 -46
  5. package/dist/attestation.js.map +1 -1
  6. package/dist/internal/attestation-core.d.ts +85 -0
  7. package/dist/internal/attestation-core.d.ts.map +1 -0
  8. package/dist/internal/attestation-core.js +78 -0
  9. package/dist/internal/attestation-core.js.map +1 -0
  10. package/dist/internal/base64-browser.d.ts +31 -0
  11. package/dist/internal/base64-browser.d.ts.map +1 -0
  12. package/dist/internal/base64-browser.js +39 -0
  13. package/dist/internal/base64-browser.js.map +1 -0
  14. package/dist/internal/base64-validate.d.ts +19 -0
  15. package/dist/internal/base64-validate.d.ts.map +1 -0
  16. package/dist/internal/base64-validate.js +33 -0
  17. package/dist/internal/base64-validate.js.map +1 -0
  18. package/dist/internal/base64.d.ts +7 -5
  19. package/dist/internal/base64.d.ts.map +1 -1
  20. package/dist/internal/base64.js +7 -19
  21. package/dist/internal/base64.js.map +1 -1
  22. package/dist/internal/content-core.d.ts +68 -0
  23. package/dist/internal/content-core.d.ts.map +1 -0
  24. package/dist/internal/content-core.js +87 -0
  25. package/dist/internal/content-core.js.map +1 -0
  26. package/dist/internal/ed25519-browser.d.ts +33 -0
  27. package/dist/internal/ed25519-browser.d.ts.map +1 -0
  28. package/dist/internal/ed25519-browser.js +79 -0
  29. package/dist/internal/ed25519-browser.js.map +1 -0
  30. package/dist/internal/mldsa-browser.d.ts +27 -0
  31. package/dist/internal/mldsa-browser.d.ts.map +1 -0
  32. package/dist/internal/mldsa-browser.js +35 -0
  33. package/dist/internal/mldsa-browser.js.map +1 -0
  34. package/dist/internal/sha256-browser.d.ts +17 -0
  35. package/dist/internal/sha256-browser.d.ts.map +1 -0
  36. package/dist/internal/sha256-browser.js +27 -0
  37. package/dist/internal/sha256-browser.js.map +1 -0
  38. package/dist/oid.d.ts +2 -52
  39. package/dist/oid.d.ts.map +1 -1
  40. package/dist/oid.js +9 -71
  41. package/dist/oid.js.map +1 -1
  42. package/dist/verify-browser.d.ts +80 -0
  43. package/dist/verify-browser.d.ts.map +1 -0
  44. package/dist/verify-browser.js +157 -0
  45. package/dist/verify-browser.js.map +1 -0
  46. package/package.json +21 -2
  47. package/src/attestation.ts +29 -88
  48. package/src/internal/attestation-core.ts +122 -0
  49. package/src/internal/base64-browser.ts +39 -0
  50. package/src/internal/base64-validate.ts +31 -0
  51. package/src/internal/base64.ts +7 -16
  52. package/src/internal/content-core.ts +88 -0
  53. package/src/internal/ed25519-browser.ts +85 -0
  54. package/src/internal/mldsa-browser.ts +39 -0
  55. package/src/internal/sha256-browser.ts +28 -0
  56. package/src/oid.ts +10 -73
  57. package/src/verify-browser.ts +185 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @synoi/sraid — internal/content-core.ts
3
+ *
4
+ * The CDRO OID content-core projection: the normative strip-set and the
5
+ * field-removal function that together define WHAT bytes an OID is computed
6
+ * over. This is a PURE module — no hashing, no node:crypto, no Buffer — so it
7
+ * is safe to import from both the node default entry (via oid.ts) and the
8
+ * browser verify surface (via verify-browser.ts) without dragging node
9
+ * builtins into a browser bundle.
10
+ *
11
+ * This is the SINGLE NORMATIVE SOURCE of the CDRO OID projection (ADR_019).
12
+ * oid.ts re-exports these symbols and layers the SHA-256 hashing on top; every
13
+ * other surface (GAP SDKs, the gateway signer, IMPLEMENTING.md) derives from
14
+ * THIS set, never re-lists it.
15
+ */
16
+
17
+ /**
18
+ * The detached-signature / envelope fields removed by `cdroContentCore`
19
+ * before hashing. This is the SINGLE NORMATIVE strip-set for the CDRO OID
20
+ * projection (ADR_019 decision 1); every other surface (GAP SDKs, the
21
+ * gateway signer, IMPLEMENTING.md) derives from THIS set, never re-lists it.
22
+ *
23
+ * The set is defined SEMANTICALLY: it is "every field produced BY the signer
24
+ * after canonicalization, plus the OID output itself." Concretely:
25
+ *
26
+ * oid — the projection OUTPUT (cannot be an input to itself).
27
+ * signature — legacy hybrid SignatureEnvelope (attaches after hash).
28
+ * ml_dsa_signature — detached PQ signature (attaches after hash).
29
+ * signature_key_id — signer-stamped key id (produced by the signer).
30
+ * signature_algorithm — signer-stamped alg id (produced by the signer).
31
+ * attestation — DSSE AttestationEnvelope (attaches after hash).
32
+ *
33
+ * EVERYTHING ELSE IS KEPT and hashed into the OID, including in particular:
34
+ * - `gap_version` — IN identity so a protocol downgrade is OID-detectable.
35
+ * - `supersedes` — IN identity because the SRAID Merkle-DAG head-proves-
36
+ * history property requires every lineage edge inside the
37
+ * hash. Superseding mints a NEW object; it never mutates
38
+ * the old one's bytes, so keeping it here is safe and
39
+ * makes the lineage edge tamper-evident.
40
+ * - `type`, `sraid_version`, `tenant_id`, `created_at_ms`, `created_by`,
41
+ * `body`, `authority`, `sensitivity`, `prev`, `links`, and any other
42
+ * content field.
43
+ *
44
+ * This is the ONE projection that yields the SAME OID whether the object is
45
+ * pre- or post-attestation: attaching an `attestation` (or `signature`,
46
+ * `ml_dsa_signature`, `signature_key_id`, `signature_algorithm`) after hashing
47
+ * is stripped back out here, so `cdroOid(obj)` is invariant across signing.
48
+ *
49
+ * It is FROZEN so no caller can mutate the normative set at runtime.
50
+ */
51
+ export const CDRO_ENVELOPE_FIELDS: readonly string[] = Object.freeze([
52
+ 'oid',
53
+ 'signature',
54
+ 'ml_dsa_signature',
55
+ 'signature_key_id',
56
+ 'signature_algorithm',
57
+ 'attestation',
58
+ ])
59
+
60
+ const CDRO_ENVELOPE_FIELD_SET: ReadonlySet<string> = new Set(CDRO_ENVELOPE_FIELDS)
61
+
62
+ /**
63
+ * Build the OID content core of a full CDRO: the object with EXACTLY the six
64
+ * detached-signature / envelope fields in `CDRO_ENVELOPE_FIELDS` removed at
65
+ * the top level, and everything else kept.
66
+ *
67
+ * This is the mechanism that makes the L4 `authority` block, the L3 lineage
68
+ * edges (`prev`, `links`, `supersedes`), the propagating `sensitivity` tier,
69
+ * and `gap_version` tamper-evident: they are hashed into identity by
70
+ * construction, so a field cannot be added, stripped, re-pointed, or
71
+ * downgraded without producing a different OID (and invalidating the
72
+ * signature, which is computed over these same bytes). Because `prev`/`links`/
73
+ * `supersedes` OIDs are inside the hash, a node's OID transitively commits its
74
+ * whole reachable history (the Merkle-DAG "head proves history" property).
75
+ *
76
+ * Returns a plain object suitable for `canonicalize` / `oidOf`.
77
+ */
78
+ export function cdroContentCore(cdro: unknown): Record<string, unknown> {
79
+ if (cdro === null || typeof cdro !== 'object' || Array.isArray(cdro)) {
80
+ throw new TypeError('cdroContentCore: argument must be a CDRO object')
81
+ }
82
+ const core: Record<string, unknown> = {}
83
+ for (const [k, v] of Object.entries(cdro as Record<string, unknown>)) {
84
+ if (CDRO_ENVELOPE_FIELD_SET.has(k)) continue
85
+ core[k] = v
86
+ }
87
+ return core
88
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @synoi/sraid — internal/ed25519-browser.ts
3
+ *
4
+ * Browser-safe Ed25519 verification. NO node:crypto.
5
+ *
6
+ * Primary path: WebCrypto (`globalThis.crypto.subtle`), which uses RFC 8032
7
+ * cofactored verification — the SAME rule OpenSSL/node:crypto uses on the node
8
+ * default entry, so the browser verifier accepts/rejects exactly what the node
9
+ * verifier does for every real signature.
10
+ *
11
+ * Fallback path: `@noble/curves` ed25519 (pure JS), used only when WebCrypto
12
+ * Ed25519 is unavailable (older embedded webviews / Chrome extensions below the
13
+ * WebCrypto-Ed25519 support floor, ~Chrome 137). @noble defaults to ZIP-215
14
+ * verification rules, which agree with cofactored on every well-formed
15
+ * signature and can differ only on deliberately malformed/malleable points that
16
+ * real signers never produce (see ed25519.ts for the same note). SynOI signs
17
+ * canonically, so the fallback is behavior-equivalent for legitimate inputs.
18
+ *
19
+ * ASYNC: WebCrypto verify is Promise-based, so this returns a Promise<boolean>.
20
+ * The browser attestation verifier is async as a result; the node path stays
21
+ * synchronous and unchanged.
22
+ *
23
+ * Returns false (never throws) on any malformed input or verification failure,
24
+ * matching the node `verifyEd25519` contract, so callers get a clean boolean.
25
+ */
26
+
27
+ import { ed25519 } from '@noble/curves/ed25519.js'
28
+
29
+ // Fixed DER prefix for an Ed25519 SubjectPublicKeyInfo (RFC 8410):
30
+ // SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING (32 raw bytes) }
31
+ // A plain Uint8Array literal (NOT Buffer, which is undefined in browsers). Used
32
+ // only for the SPKI import fallback below.
33
+ const ED25519_SPKI_PREFIX = new Uint8Array([
34
+ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
35
+ ])
36
+
37
+ const ED25519_ALG = { name: 'Ed25519' } as const
38
+
39
+ // Return type is inferred (webcrypto.CryptoKey); the bare `CryptoKey` global is
40
+ // not declared by @types/node ^20, so it is deliberately left un-annotated.
41
+ async function importPublicKey(publicKeyRaw: Uint8Array) {
42
+ const subtle = globalThis.crypto.subtle
43
+ try {
44
+ // Preferred: raw 32-byte import (Secure Curves spec).
45
+ return await subtle.importKey('raw', publicKeyRaw, ED25519_ALG, false, ['verify'])
46
+ } catch {
47
+ // Some engines only accept SPKI for Ed25519 import; wrap the raw key.
48
+ const spki = new Uint8Array(ED25519_SPKI_PREFIX.length + publicKeyRaw.length)
49
+ spki.set(ED25519_SPKI_PREFIX, 0)
50
+ spki.set(publicKeyRaw, ED25519_SPKI_PREFIX.length)
51
+ return await subtle.importKey('spki', spki, ED25519_ALG, false, ['verify'])
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Verify a raw 64-byte Ed25519 signature over `message` against a raw 32-byte
57
+ * public key. Prefers WebCrypto (cofactored, matching the node path); falls
58
+ * back to @noble/curves only when WebCrypto Ed25519 is not supported. Returns
59
+ * false on any malformed input or verification failure; never throws.
60
+ */
61
+ export async function verifyEd25519Browser(
62
+ signature: Uint8Array,
63
+ message: Uint8Array,
64
+ publicKeyRaw: Uint8Array,
65
+ ): Promise<boolean> {
66
+ if (publicKeyRaw.length !== 32) return false
67
+
68
+ const subtle = globalThis.crypto?.subtle
69
+ if (subtle) {
70
+ try {
71
+ const key = await importPublicKey(publicKeyRaw)
72
+ return await subtle.verify(ED25519_ALG, key, signature, message)
73
+ } catch {
74
+ // Fall through to @noble. A WebCrypto throw here is either an
75
+ // unsupported-algorithm signal or a malformed input; @noble resolves both
76
+ // correctly (a real bad signature/key verifies as false there too).
77
+ }
78
+ }
79
+
80
+ try {
81
+ return ed25519.verify(signature, message, publicKeyRaw)
82
+ } catch {
83
+ return false
84
+ }
85
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @synoi/sraid — internal/mldsa-browser.ts
3
+ *
4
+ * Browser-safe ML-DSA-65 (FIPS 204) verification. NO node:crypto.
5
+ *
6
+ * Browsers ship no native ML-DSA, so there is only one path here: the pure-JS
7
+ * `@noble/post-quantum` implementation, which is fully browser-safe. The node
8
+ * default entry (mldsa.ts) uses a native OpenSSL fast path with this same
9
+ * @noble impl as its fallback, so both entries are byte-for-byte interoperable.
10
+ *
11
+ * IMPORTANT: import `ml_dsa65` DIRECTLY from `@noble/post-quantum/ml-dsa.js`,
12
+ * NOT from ./mldsa.ts — that module statically imports `node:crypto` (for its
13
+ * native fast path and the globalThis.crypto polyfill), which would poison a
14
+ * browser bundle. No globalThis.crypto polyfill is needed here: browsers,
15
+ * workers, and MV3 service workers all provide it natively, and ml_dsa65.verify
16
+ * is deterministic (it never calls getRandomValues; only keygen/sign do).
17
+ *
18
+ * Verification is SYNCHRONOUS. Returns false (never throws) on any malformed
19
+ * input or verification failure, matching the node `verifyMlDsa65` contract.
20
+ */
21
+
22
+ import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
23
+
24
+ /**
25
+ * Verify a raw ML-DSA-65 signature over `message` against a raw 1952-byte
26
+ * public key using @noble/post-quantum. Returns false on any malformed input or
27
+ * verification failure; never throws.
28
+ */
29
+ export function verifyMlDsa65Browser(
30
+ signature: Uint8Array,
31
+ message: Uint8Array,
32
+ publicKeyRaw: Uint8Array,
33
+ ): boolean {
34
+ try {
35
+ return ml_dsa65.verify(signature, message, publicKeyRaw)
36
+ } catch {
37
+ return false
38
+ }
39
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @synoi/sraid — internal/sha256-browser.ts
3
+ *
4
+ * Browser-safe SHA-256 via WebCrypto (`globalThis.crypto.subtle.digest`). NO
5
+ * node:crypto. Used by the browser OID helpers in verify-browser.ts.
6
+ *
7
+ * ASYNC: subtle.digest is Promise-based, so the browser OID helpers are async
8
+ * (the node oid.ts helpers stay synchronous via node:crypto createHash and are
9
+ * unchanged). SHA-256 is byte-identical across any conformant implementation,
10
+ * so a browser-computed OID equals the node-computed one for the same input.
11
+ */
12
+
13
+ function toHex(bytes: Uint8Array): string {
14
+ let out = ''
15
+ for (let i = 0; i < bytes.length; i++) {
16
+ out += (bytes[i] as number).toString(16).padStart(2, '0')
17
+ }
18
+ return out
19
+ }
20
+
21
+ /**
22
+ * Compute `sha256:` + lowercase hex of SHA-256(bytes) using WebCrypto. Mirrors
23
+ * the `sha256:`-prefixed output of the node oid.ts helpers.
24
+ */
25
+ export async function sha256HexPrefixed(bytes: Uint8Array): Promise<string> {
26
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
27
+ return 'sha256:' + toHex(new Uint8Array(digest))
28
+ }
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
@@ -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
+ }