@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.
Files changed (86) hide show
  1. package/README.md +156 -50
  2. package/SPEC.md +2 -2
  3. package/dist/attestation.d.ts +8 -49
  4. package/dist/attestation.d.ts.map +1 -1
  5. package/dist/attestation.js +15 -46
  6. package/dist/attestation.js.map +1 -1
  7. package/dist/capability.d.ts +59 -0
  8. package/dist/capability.d.ts.map +1 -0
  9. package/dist/capability.js +44 -0
  10. package/dist/capability.js.map +1 -0
  11. package/dist/index.d.ts +13 -13
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +26 -18
  14. package/dist/index.js.map +1 -1
  15. package/dist/internal/attestation-core.d.ts +85 -0
  16. package/dist/internal/attestation-core.d.ts.map +1 -0
  17. package/dist/internal/attestation-core.js +78 -0
  18. package/dist/internal/attestation-core.js.map +1 -0
  19. package/dist/internal/base64-browser.d.ts +31 -0
  20. package/dist/internal/base64-browser.d.ts.map +1 -0
  21. package/dist/internal/base64-browser.js +39 -0
  22. package/dist/internal/base64-browser.js.map +1 -0
  23. package/dist/internal/base64-validate.d.ts +19 -0
  24. package/dist/internal/base64-validate.d.ts.map +1 -0
  25. package/dist/internal/base64-validate.js +33 -0
  26. package/dist/internal/base64-validate.js.map +1 -0
  27. package/dist/internal/base64.d.ts +7 -5
  28. package/dist/internal/base64.d.ts.map +1 -1
  29. package/dist/internal/base64.js +7 -19
  30. package/dist/internal/base64.js.map +1 -1
  31. package/dist/internal/content-core.d.ts +68 -0
  32. package/dist/internal/content-core.d.ts.map +1 -0
  33. package/dist/internal/content-core.js +87 -0
  34. package/dist/internal/content-core.js.map +1 -0
  35. package/dist/internal/ed25519-browser.d.ts +33 -0
  36. package/dist/internal/ed25519-browser.d.ts.map +1 -0
  37. package/dist/internal/ed25519-browser.js +79 -0
  38. package/dist/internal/ed25519-browser.js.map +1 -0
  39. package/dist/internal/mldsa-browser.d.ts +27 -0
  40. package/dist/internal/mldsa-browser.d.ts.map +1 -0
  41. package/dist/internal/mldsa-browser.js +35 -0
  42. package/dist/internal/mldsa-browser.js.map +1 -0
  43. package/dist/internal/sha256-browser.d.ts +17 -0
  44. package/dist/internal/sha256-browser.d.ts.map +1 -0
  45. package/dist/internal/sha256-browser.js +27 -0
  46. package/dist/internal/sha256-browser.js.map +1 -0
  47. package/dist/lineage.js +0 -0
  48. package/dist/lineage.js.map +1 -1
  49. package/dist/oid.d.ts +2 -52
  50. package/dist/oid.d.ts.map +1 -1
  51. package/dist/oid.js +9 -71
  52. package/dist/oid.js.map +1 -1
  53. package/dist/types.d.ts +4 -30
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +19 -0
  56. package/dist/types.js.map +1 -1
  57. package/dist/validate.d.ts +2 -17
  58. package/dist/validate.d.ts.map +1 -1
  59. package/dist/validate.js +0 -57
  60. package/dist/validate.js.map +1 -1
  61. package/dist/verify-browser.d.ts +80 -0
  62. package/dist/verify-browser.d.ts.map +1 -0
  63. package/dist/verify-browser.js +157 -0
  64. package/dist/verify-browser.js.map +1 -0
  65. package/package.json +22 -3
  66. package/src/attestation.ts +29 -88
  67. package/src/capability.ts +69 -0
  68. package/src/index.ts +27 -36
  69. package/src/internal/attestation-core.ts +122 -0
  70. package/src/internal/base64-browser.ts +39 -0
  71. package/src/internal/base64-validate.ts +31 -0
  72. package/src/internal/base64.ts +7 -16
  73. package/src/internal/content-core.ts +88 -0
  74. package/src/internal/ed25519-browser.ts +85 -0
  75. package/src/internal/mldsa-browser.ts +39 -0
  76. package/src/internal/sha256-browser.ts +28 -0
  77. package/src/lineage.ts +0 -0
  78. package/src/oid.ts +10 -73
  79. package/src/types.ts +23 -31
  80. package/src/validate.ts +1 -64
  81. package/src/verify-browser.ts +185 -0
  82. package/dist/authority.d.ts +0 -351
  83. package/dist/authority.d.ts.map +0 -1
  84. package/dist/authority.js +0 -563
  85. package/dist/authority.js.map +0 -1
  86. package/src/authority.ts +0 -849
package/src/index.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * @synoi/sraid — public surface.
3
3
  *
4
- * SRAID (Self-Routing Addressable Identity Data) is L0 of the SynOI SRAID Stack:
4
+ * SRAID is the object-identity layer: it defines what a signed object IS and
5
+ * how anyone re-derives and verifies its identity offline.
5
6
  *
6
- * L0 SRAID ← this package
7
- * L1 Vault / Resolver
8
- * L2 Inference Broker / Resonance
9
- * L3 GAP (Governed Action Protocol)
10
- * L4 Apps
7
+ * Scope, precisely:
8
+ * IN canonical serializer, OID computation, hybrid Ed25519 + ML-DSA-65
9
+ * attestation verification, lineage resolution, shape validators.
10
+ * OUT storage, transport, key resolution, revocation, policy, enforcement.
11
11
  *
12
- * This package is intentionally small: types, canonical serializer,
13
- * OID computation, hybrid signature verification, and shape validators.
14
- * No storage, no HTTP, no governance those are higher layers.
12
+ * It verifies SIGNATURES, not IDENTITIES: `signer_kid` is an opaque string
13
+ * this package never resolves, and nothing here checks revocation. Deciding
14
+ * whether a key was trusted at signing time is a caller/directory concern.
15
15
  */
16
16
 
17
17
  // Types
@@ -23,8 +23,6 @@ export type {
23
23
  CDRO,
24
24
  LineageLink,
25
25
  LinkRel,
26
- SRO,
27
- SROBody,
28
26
  SensitivityTier,
29
27
  SignatureEnvelope,
30
28
  } from './types.js'
@@ -53,7 +51,7 @@ export { verifyMlDsa65, isNativeMlDsaAvailable } from './mldsa.js'
53
51
  // rotated or compromised key cannot be served stale on a later verify.
54
52
  export { evictKeyFromCaches } from './internal/key-cache.js'
55
53
 
56
- // L2 DSSE attestation — PAE type-binding, hybrid Ed25519 + ML-DSA-65 both
54
+ // DSSE attestation — PAE type-binding, hybrid Ed25519 + ML-DSA-65 both
57
55
  // required. The preferred signing path (replaces the legacy bare-bytes
58
56
  // SignatureEnvelope; closes the cross-type confusion gap SRAID F7 / A4).
59
57
  export {
@@ -65,7 +63,8 @@ export {
65
63
  type VerifyAttestationResult,
66
64
  } from './attestation.js'
67
65
 
68
- // L3 lineage (Merkle-DAG) — unify supersedes/SRO/prev; latest-wins resolution.
66
+ // Lineage (Merkle-DAG) — unify the `supersedes` string with the identity-bound
67
+ // `prev`/`links` edges; latest-wins resolution over a version set.
69
68
  export {
70
69
  lineageLinks,
71
70
  supersededOids,
@@ -73,7 +72,7 @@ export {
73
72
  type LatestWinsResult,
74
73
  } from './lineage.js'
75
74
 
76
- // L4 propagating sensitivity — coarse, opaque tier + monotone max() carry-forward.
75
+ // Propagating sensitivity — coarse, opaque tier + monotone max() carry-forward.
77
76
  export {
78
77
  SENSITIVITY_TIERS,
79
78
  SENSITIVITY_DEFAULT,
@@ -84,35 +83,27 @@ export {
84
83
  sensitivityMonotoneCheck,
85
84
  } from './sensitivity.js'
86
85
 
87
- // L4 authority verification (the authorized axis).
88
- export {
89
- verifyAuthority,
90
- capabilityCovers,
91
- type VerifyAuthorityInput,
92
- type VerifyAuthorityResult,
93
- type AuthorityResolver,
94
- type GrantStatus,
95
- type GrantBodyShape,
96
- } from './authority.js'
97
-
98
- // L4 delegation-chain verification (K2) — VERIFY-ONLY, offline, no enforcement.
99
- // Always reports revocation_checked/existence_checked false (no live claim).
100
- export {
101
- verifyDelegationChain,
102
- MAX_DELEGATION_DEPTH,
103
- type VerifyDelegationChainInput,
104
- type VerifyDelegationChainResult,
105
- type HopResult,
106
- type LinkPubkeys,
107
- } from './authority.js'
86
+ // Capability matching + the live-grant-status contract. The leaf primitive a
87
+ // grant store needs: pure string logic, no crypto, no I/O, no live claim.
88
+ export { capabilityCovers } from './capability.js'
89
+ export type { AuthorityResolver, GrantStatus } from './capability.js'
108
90
 
109
91
  // Shape validators.
110
92
  export {
111
93
  validateCdro,
112
- validateSro,
113
94
  validateSignatureEnvelope,
114
95
  validateAttestationEnvelope,
115
96
  validateAuthorityBlock,
116
97
  validateLineageLink,
117
98
  type ValidationResult,
118
99
  } from './validate.js'
100
+
101
+ // MOVED IN 0.4.0 — the grant and delegation-chain VERIFIERS (`verifyAuthority`,
102
+ // `verifyDelegationChain`, `MAX_DELEGATION_DEPTH`) are authorization policy,
103
+ // not object identity, and are no longer exported from this entry. They live
104
+ // at the `@synoi/sraid/authority` subpath, unchanged and still verify-only.
105
+ //
106
+ // `capabilityCovers`, `AuthorityResolver` and `GrantStatus` did NOT move: they
107
+ // are the leaf primitive and the contract, and are exported above.
108
+ //
109
+ // REMOVED IN 0.4.0 — `validateSro`, `SRO`, `SROBody`. See CHANGELOG 0.4.0.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @synoi/sraid — internal/attestation-core.ts
3
+ *
4
+ * The crypto-agnostic core of the L2 DSSE attestation profile: the PAE
5
+ * encoding, the algorithm identifiers, the public verify input/result types,
6
+ * the both-required signature lookup, and the envelope-shape predicate.
7
+ *
8
+ * This is a PURE module — no crypto, no node:crypto, no Buffer — so the same
9
+ * PAE bytes, AND-policy shape, and type contract are shared byte-for-byte by
10
+ * BOTH the node default entry (attestation.ts, node:crypto verifiers) and the
11
+ * browser verify surface (verify-browser.ts, WebCrypto + @noble verifiers).
12
+ * Divergent PAE or shape logic between the two would be a signature-validity
13
+ * hazard; keeping it in one place removes that risk.
14
+ *
15
+ * The verify ORCHESTRATION (decode each sig, run both verifiers, gate on the
16
+ * AND policy) is intentionally NOT here: the node path is synchronous
17
+ * (node:crypto verify) and the browser path is asynchronous (WebCrypto verify),
18
+ * so a single shared body cannot serve both. Each entry keeps its own thin
19
+ * orchestration and reuses these leaf helpers.
20
+ */
21
+
22
+ import type { AttestationEnvelope, AttestationSignature } from '../types.js'
23
+
24
+ /** The single supported algorithm identifiers for the JSON profile. */
25
+ export const ALG_ED25519 = 'ed25519'
26
+ export const ALG_ML_DSA_65 = 'ml-dsa-65'
27
+
28
+ /**
29
+ * The DSSE Pre-Authentication Encoding (PAE).
30
+ *
31
+ * Per the DSSE spec:
32
+ *
33
+ * PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
34
+ *
35
+ * where:
36
+ * - SP is a single ASCII space (0x20),
37
+ * - LEN(x) is the ASCII-decimal byte length of x's UTF-8 encoding,
38
+ * - type and body are the raw UTF-8 bytes (NOT base64).
39
+ *
40
+ * Binding the payloadType (and both lengths) into the signed bytes is what
41
+ * makes a signature non-transferable across object types. Returns the raw
42
+ * bytes to be signed/verified.
43
+ */
44
+ export function pae(payloadType: string, payload: string | Uint8Array): Uint8Array {
45
+ const enc = new TextEncoder()
46
+ const typeBytes = enc.encode(payloadType)
47
+ const bodyBytes = typeof payload === 'string' ? enc.encode(payload) : payload
48
+
49
+ const prefix = enc.encode(
50
+ `DSSEv1 ${typeBytes.length} ${payloadType} ${bodyBytes.length} `,
51
+ )
52
+ const out = new Uint8Array(prefix.length + bodyBytes.length)
53
+ out.set(prefix, 0)
54
+ out.set(bodyBytes, prefix.length)
55
+ return out
56
+ }
57
+
58
+ export interface VerifyAttestationInput {
59
+ /**
60
+ * The DSSE attestation envelope to verify. Its `payloadType` is bound into
61
+ * the PAE; its `payload` is the canonical UTF-8 string that was signed.
62
+ */
63
+ envelope: AttestationEnvelope
64
+ /** Raw 32-byte Ed25519 public key. */
65
+ ed25519_pub: Uint8Array
66
+ /** Raw ML-DSA-65 public key bytes. */
67
+ ml_dsa_pub: Uint8Array
68
+ /**
69
+ * Optional. If supplied, the verifier asserts the envelope's `payloadType`
70
+ * equals this value before verifying signatures — an explicit type-pinning
71
+ * check on top of the structural PAE binding. A mismatch fails with
72
+ * `payload-type-mismatch`.
73
+ */
74
+ expectedPayloadType?: string
75
+ }
76
+
77
+ export interface VerifyAttestationResult {
78
+ /** True only when BOTH required signatures verified over the same PAE. */
79
+ valid: boolean
80
+ /**
81
+ * Human-readable failure reasons. Empty when valid. Possible values:
82
+ * 'envelope-malformed', 'payload-type-mismatch', 'missing-ed25519',
83
+ * 'missing-ml-dsa-65', 'ed25519-malformed', 'ml-dsa-malformed',
84
+ * 'ed25519-invalid', 'ml-dsa-invalid'.
85
+ */
86
+ reasons: string[]
87
+ }
88
+
89
+ /**
90
+ * Structural well-formedness of an attestation envelope: a non-null object with
91
+ * a non-empty string `payloadType`, a string `payload`, and an array
92
+ * `signatures`. A false result maps to the `envelope-malformed` reason. This is
93
+ * the SINGLE definition of envelope shape, shared by the node and browser
94
+ * verifiers so they reject the same inputs.
95
+ */
96
+ export function isWellFormedEnvelope(env: unknown): env is AttestationEnvelope {
97
+ return (
98
+ env !== null &&
99
+ typeof env === 'object' &&
100
+ typeof (env as AttestationEnvelope).payloadType === 'string' &&
101
+ (env as AttestationEnvelope).payloadType.length > 0 &&
102
+ typeof (env as AttestationEnvelope).payload === 'string' &&
103
+ Array.isArray((env as AttestationEnvelope).signatures)
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Find the first well-formed signature entry for `alg` (a `{ alg, sig }` object
109
+ * whose `sig` is a string). Returns undefined when absent; the AND policy in
110
+ * each verifier turns a missing pair member into a `missing-*` reason.
111
+ */
112
+ export function findSig(
113
+ sigs: readonly AttestationSignature[],
114
+ alg: string,
115
+ ): AttestationSignature | undefined {
116
+ for (const s of sigs) {
117
+ if (s && typeof s === 'object' && s.alg === alg && typeof s.sig === 'string') {
118
+ return s
119
+ }
120
+ }
121
+ return undefined
122
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @synoi/sraid — internal/base64-browser.ts
3
+ *
4
+ * Strict standard-base64 (RFC 4648 §4, with `=` padding) decode for the
5
+ * BROWSER verify surface.
6
+ *
7
+ * INTERNAL: not exported from any public entry.
8
+ *
9
+ * Why a separate decoder: the node decoder (./base64.ts `decodeBase64Strict`)
10
+ * finishes with `Buffer.from(s, 'base64')`. `Buffer` is a NODE global that does
11
+ * not exist in a browser / service-worker / Chrome-extension context — and,
12
+ * crucially, it is NOT a `node:` specifier, so an esbuild `node:`-reference scan
13
+ * would NOT catch it. A `Buffer`-based decode would bundle clean yet throw
14
+ * `ReferenceError: Buffer is not defined` at runtime. This decoder is
15
+ * Buffer-free: it reuses the pure `assertBase64` validation (regex/length/
16
+ * padding only, from ./base64-validate.js — NOT ./base64.js, whose graph pulls
17
+ * in the `Buffer` decode) and decodes via the standard `atob` global, which IS
18
+ * available in browsers, dedicated/shared workers, and MV3 extension service
19
+ * workers.
20
+ *
21
+ * Same strict contract as the node path: throws Error('base64-malformed') on
22
+ * any deviation (illegal char, bad length, bad padding) rather than silently
23
+ * truncating, so a malformed signature is rejected loudly, never decoded to a
24
+ * short prefix that then "verifies" against something unintended.
25
+ */
26
+
27
+ import { assertBase64 } from './base64-validate.js'
28
+
29
+ /**
30
+ * Strictly decode standard base64 to raw bytes using only browser-safe globals.
31
+ * Throws Error('base64-malformed') on any non-conforming input.
32
+ */
33
+ export function decodeBase64StrictBrowser(s: string): Uint8Array {
34
+ assertBase64(s)
35
+ const bin = atob(s)
36
+ const out = new Uint8Array(bin.length)
37
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
38
+ return out
39
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @synoi/sraid — internal/base64-validate.ts
3
+ *
4
+ * Pure strict standard-base64 (RFC 4648 §4, with `=` padding) VALIDATION.
5
+ *
6
+ * INTERNAL: not exported from any public entry.
7
+ *
8
+ * This is the Buffer-free, node:crypto-free half of base64 handling, split out
9
+ * so the browser decoder (./base64-browser.ts) can validate without importing
10
+ * ./base64.ts (whose `decodeBase64Strict` uses the node `Buffer` global — safe
11
+ * in node, a runtime `ReferenceError` in a browser). The node decoder re-uses
12
+ * this same validator, so both paths reject exactly the same inputs.
13
+ */
14
+
15
+ // Standard base64 alphabet; `=` only as 1-2 trailing pad chars. Total length
16
+ // must be a multiple of 4.
17
+ const STD_B64 = /^[A-Za-z0-9+/]*={0,2}$/
18
+
19
+ /**
20
+ * Validate strict standard base64. Throws Error('base64-malformed') on any
21
+ * non-conforming input.
22
+ */
23
+ export function assertBase64(s: string): void {
24
+ if (typeof s !== 'string') throw new Error('base64-malformed')
25
+ if (s.length % 4 !== 0) throw new Error('base64-malformed')
26
+ if (!STD_B64.test(s)) throw new Error('base64-malformed')
27
+ // `=` may appear only in the final 1-2 positions. The regex `={0,2}` anchored
28
+ // at end already guarantees pad chars are contiguous and trailing, but a pad
29
+ // char earlier in the body would have been rejected by the alphabet class —
30
+ // so an in-body `=` cannot pass STD_B64. No extra check needed.
31
+ }
@@ -14,25 +14,16 @@
14
14
  *
15
15
  * Canonical inputs carry no surrounding whitespace, so no trim is applied;
16
16
  * leading/trailing whitespace is itself a rejection.
17
+ *
18
+ * The strict VALIDATION (alphabet/length/padding) lives in the pure, Buffer-
19
+ * free ./base64-validate.ts so the browser decoder can share it; this module
20
+ * adds the node `Buffer`-based decode on top and re-exports `assertBase64` so
21
+ * existing importers of `./base64.js` are unchanged.
17
22
  */
18
23
 
19
- // Standard base64 alphabet; `=` only as 1-2 trailing pad chars. Total length
20
- // must be a multiple of 4.
21
- const STD_B64 = /^[A-Za-z0-9+/]*={0,2}$/
24
+ import { assertBase64 } from './base64-validate.js'
22
25
 
23
- /**
24
- * Validate strict standard base64. Throws Error('base64-malformed') on any
25
- * non-conforming input.
26
- */
27
- export function assertBase64(s: string): void {
28
- if (typeof s !== 'string') throw new Error('base64-malformed')
29
- if (s.length % 4 !== 0) throw new Error('base64-malformed')
30
- if (!STD_B64.test(s)) throw new Error('base64-malformed')
31
- // `=` may appear only in the final 1-2 positions. The regex `={0,2}` anchored
32
- // at end already guarantees pad chars are contiguous and trailing, but a pad
33
- // char earlier in the body would have been rejected by the alphabet class —
34
- // so an in-body `=` cannot pass STD_B64. No extra check needed.
35
- }
26
+ export { assertBase64 }
36
27
 
37
28
  /**
38
29
  * Strictly decode standard base64 to raw bytes. Throws
@@ -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/lineage.ts CHANGED
Binary file