@synoi/sraid 0.2.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/PROJECTION_SPEC.md +245 -0
  3. package/README.md +149 -0
  4. package/SPEC.md +216 -0
  5. package/dist/attestation.d.ts +92 -0
  6. package/dist/attestation.d.ts.map +1 -0
  7. package/dist/attestation.js +155 -0
  8. package/dist/attestation.js.map +1 -0
  9. package/dist/authority.d.ts +351 -0
  10. package/dist/authority.d.ts.map +1 -0
  11. package/dist/authority.js +563 -0
  12. package/dist/authority.js.map +1 -0
  13. package/dist/canonicalize.d.ts +70 -0
  14. package/dist/canonicalize.d.ts.map +1 -0
  15. package/dist/canonicalize.js +151 -0
  16. package/dist/canonicalize.js.map +1 -0
  17. package/dist/ed25519.d.ts +26 -0
  18. package/dist/ed25519.d.ts.map +1 -0
  19. package/dist/ed25519.js +53 -0
  20. package/dist/ed25519.js.map +1 -0
  21. package/dist/index.d.ts +28 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +46 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/internal/base64.d.ts +28 -0
  26. package/dist/internal/base64.d.ts.map +1 -0
  27. package/dist/internal/base64.js +45 -0
  28. package/dist/internal/base64.js.map +1 -0
  29. package/dist/internal/key-cache.d.ts +47 -0
  30. package/dist/internal/key-cache.d.ts.map +1 -0
  31. package/dist/internal/key-cache.js +77 -0
  32. package/dist/internal/key-cache.js.map +1 -0
  33. package/dist/lineage.d.ts +104 -0
  34. package/dist/lineage.d.ts.map +1 -0
  35. package/dist/lineage.js +0 -0
  36. package/dist/lineage.js.map +1 -0
  37. package/dist/mldsa.d.ts +41 -0
  38. package/dist/mldsa.d.ts.map +1 -0
  39. package/dist/mldsa.js +119 -0
  40. package/dist/mldsa.js.map +1 -0
  41. package/dist/oid.d.ts +109 -0
  42. package/dist/oid.d.ts.map +1 -0
  43. package/dist/oid.js +140 -0
  44. package/dist/oid.js.map +1 -0
  45. package/dist/sensitivity.d.ts +104 -0
  46. package/dist/sensitivity.d.ts.map +1 -0
  47. package/dist/sensitivity.js +117 -0
  48. package/dist/sensitivity.js.map +1 -0
  49. package/dist/signature.d.ts +46 -0
  50. package/dist/signature.d.ts.map +1 -0
  51. package/dist/signature.js +89 -0
  52. package/dist/signature.js.map +1 -0
  53. package/dist/types.d.ts +313 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +16 -0
  56. package/dist/types.js.map +1 -0
  57. package/dist/validate.d.ts +88 -0
  58. package/dist/validate.d.ts.map +1 -0
  59. package/dist/validate.js +339 -0
  60. package/dist/validate.js.map +1 -0
  61. package/package.json +69 -0
  62. package/src/attestation.ts +204 -0
  63. package/src/authority.ts +849 -0
  64. package/src/canonicalize.ts +167 -0
  65. package/src/ed25519.ts +60 -0
  66. package/src/index.ts +118 -0
  67. package/src/internal/base64.ts +44 -0
  68. package/src/internal/key-cache.ts +79 -0
  69. package/src/lineage.ts +0 -0
  70. package/src/mldsa.ts +131 -0
  71. package/src/oid.ts +146 -0
  72. package/src/sensitivity.ts +154 -0
  73. package/src/signature.ts +119 -0
  74. package/src/types.ts +351 -0
  75. package/src/validate.ts +402 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @synoi/sraid — canonicalize.ts
3
+ *
4
+ * Strict RFC 8785 (JCS) serializer for SRAID objects. Produces
5
+ * byte-identical output to any other conformant RFC 8785 implementation
6
+ * given the same input, making this the normative canonical form for OID
7
+ * derivation and signing.
8
+ *
9
+ * Rules (strict RFC 8785 JCS):
10
+ * - Primitives are emitted via JSON.stringify (strings are escaped with
11
+ * standard JSON string escaping; booleans and null are literals).
12
+ * - Numbers must be FINITE INTEGERS (ADR_019 decision 2). NaN, Infinity
13
+ * (positive or negative), and any non-integer (float) are REJECTED with
14
+ * a thrown TypeError BEFORE hashing. Forbidding floats everywhere matches
15
+ * GAP-TS / GAP-Python and removes the hardest RFC 8785 cross-language
16
+ * serialization trap from the signed byte string; the product only emits
17
+ * integer minor-unit money and integer ms timestamps. Legal integers use
18
+ * the ECMAScript number-to-string serialization of RFC 8785 §3.2.2.3
19
+ * (equivalent to JS's default toString for integer values).
20
+ * - Arrays preserve element order; elements are recursively canonicalized.
21
+ * - Objects emit their keys sorted in ascending lexicographic
22
+ * (UTF-16 code-unit) order — this is the order produced by
23
+ * Array.prototype.sort(), which is exactly what RFC 8785 §3.2.3
24
+ * specifies for implementations whose host language uses UTF-16
25
+ * string encoding (including ECMAScript/TypeScript).
26
+ * - Object properties whose value is `undefined` are OMITTED entirely.
27
+ * (JSON has no undefined; this matches JSON.stringify semantics.)
28
+ * - No whitespace anywhere. Separators are bare `,` and `:`.
29
+ * - `null` is preserved as `null`.
30
+ *
31
+ * Stability invariant: any change to this function breaks every signed
32
+ * receipt, every OID, every grant — so it must NEVER be changed without
33
+ * a coordinated migration. The output bytes for a given input are a
34
+ * cross-package contract.
35
+ */
36
+
37
+ /**
38
+ * Canonicalize an arbitrary JSON-compatible value into a deterministic
39
+ * UTF-8 string following RFC 8785 (JCS). The output is suitable as input
40
+ * to SHA-256 (or any other hash) for OID derivation and as the byte string
41
+ * signed by Ed25519 / ML-DSA-65 envelopes.
42
+ *
43
+ * Reject-loud contract: a TypeError is thrown for any value that is not a
44
+ * JSON value, rather than silently producing a wrong or invalid canonical
45
+ * form (which would corrupt an OID or signature):
46
+ * - NaN / Infinity / -Infinity (RFC 8785 forbids non-finite numbers).
47
+ * - non-integer numbers / floats (ADR_019 forbids them; use integer minor
48
+ * units). e.g. 1.5, 0.1, 2e-3 all throw.
49
+ * - undefined / function / symbol / bigint anywhere they appear as a value,
50
+ * including as an ARRAY element (previously these produced invalid JSON
51
+ * like "[1,,2]").
52
+ * - objects with a toJSON() method (e.g. Date), which a bare keys-walk would
53
+ * silently mis-serialize (a Date became "{}"). Convert to a JSON value
54
+ * (e.g. an ISO string) first.
55
+ * Object properties whose value is `undefined` are OMITTED (matches
56
+ * JSON.stringify); an undefined array element throws (it cannot be omitted).
57
+ *
58
+ * @example
59
+ * canonicalize({ b: 2, a: 1 }) // '{"a":1,"b":2}'
60
+ * canonicalize({ a: undefined, b: 1 }) // '{"b":1}' (object undefined omitted)
61
+ * canonicalize([3, 1, 2]) // '[3,1,2]'
62
+ * canonicalize([1, null, 2]) // '[1,null,2]'
63
+ * canonicalize({ foo: 1, bar: 'hi' }) // '{"bar":"hi","foo":1}'
64
+ * canonicalize(NaN) // throws TypeError
65
+ * canonicalize(Infinity) // throws TypeError
66
+ * canonicalize(1.5) // throws TypeError (floats forbidden, ADR_019)
67
+ * canonicalize([1, undefined, 2]) // throws TypeError (no empty slots)
68
+ * canonicalize(new Date()) // throws TypeError (serialize first)
69
+ */
70
+ export function canonicalize(value: unknown): string {
71
+ const t = typeof value
72
+
73
+ if (t === 'number') {
74
+ if (!isFinite(value as number)) {
75
+ throw new TypeError(
76
+ `canonicalize: RFC 8785 forbids non-finite numbers; received ${String(value)}`,
77
+ )
78
+ }
79
+ // ADR_019 decision 2: FORBID non-integer numbers everywhere. A number is
80
+ // legal iff it is a finite integer. Floats are rejected BEFORE hashing,
81
+ // matching GAP-TS / GAP-Python, so a float-bearing object cannot mint an
82
+ // OID on one surface that another surface declares malformed. This kills
83
+ // the hardest RFC 8785 cross-language trap (float shortest-round-trip
84
+ // serialization) on a signed byte string; the product only ever emits
85
+ // integer minor-unit money and integer millisecond timestamps.
86
+ //
87
+ // Number.isInteger(-0) is true and, with floats forbidden, -0 can only
88
+ // arise as an explicit input; JSON.stringify(-0) === '0', so the former
89
+ // -0 special-case is now redundant and is DELETED (removing a divergence
90
+ // source per ADR_019 rather than carrying a rule that can never fire on
91
+ // legal input).
92
+ if (!Number.isInteger(value as number)) {
93
+ throw new TypeError(
94
+ `canonicalize: non-integer numbers are forbidden (ADR_019); received ${String(value)}. ` +
95
+ 'Represent fractional quantities as integer minor units (e.g. cents) before canonicalizing.',
96
+ )
97
+ }
98
+ return JSON.stringify(value)
99
+ }
100
+
101
+ if (value === null) return 'null'
102
+ if (t === 'string' || t === 'boolean') return JSON.stringify(value as string | boolean)
103
+
104
+ // Reject-loud: a value that is not a JSON value MUST throw, never silently
105
+ // produce wrong or invalid output. Previously these fell through to
106
+ // JSON.stringify, which returns the JS value `undefined` for undefined /
107
+ // function / symbol — corrupting an array element into invalid JSON like
108
+ // "[1,,2]" — and throws for bigint. An OID/signature must never be derived
109
+ // from a corrupted or out-of-domain canonical form.
110
+ if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') {
111
+ throw new TypeError(
112
+ `canonicalize: value of type "${t}" is not a JSON value and cannot be canonicalized`,
113
+ )
114
+ }
115
+
116
+ // value is a non-null object from here.
117
+ if (Array.isArray(value)) {
118
+ // Each element is canonicalized recursively. We walk by index rather than
119
+ // Array.prototype.map because map SKIPS holes in a sparse array — a hole
120
+ // (`[1, , 2]`, distinct from an explicit `[1, undefined, 2]`) is never
121
+ // visited by map, so it would slip past the reject-loud element checks and
122
+ // join() would emit invalid, index-shifting JSON like "[1,,2]". A hole is
123
+ // out-of-domain input (JSON has no concept of a missing element); per the
124
+ // reject-loud contract it throws, exactly as an explicit undefined element
125
+ // does, rather than silently coercing to null.
126
+ const parts: string[] = []
127
+ for (let i = 0; i < value.length; i++) {
128
+ if (!(i in value)) {
129
+ throw new TypeError(
130
+ `canonicalize: sparse array hole at index ${i} is not a JSON value and ` +
131
+ 'cannot be canonicalized (it would produce invalid JSON like "[1,,2]"); ' +
132
+ 'fill the slot with an explicit value (e.g. null) before canonicalizing',
133
+ )
134
+ }
135
+ parts.push(canonicalize(value[i]))
136
+ }
137
+ return '[' + parts.join(',') + ']'
138
+ }
139
+
140
+ // Reject objects that define their own JSON projection (Date, Buffer,
141
+ // Decimal, BigNumber, ...). RFC 8785 / JSON.stringify call toJSON() first; a
142
+ // bare keys-walk does not, so a Date would silently canonicalize to "{}" and
143
+ // fork the OID. Callers must convert to a JSON value (e.g. an ISO string)
144
+ // before canonicalizing.
145
+ if (typeof (value as { toJSON?: unknown }).toJSON === 'function') {
146
+ throw new TypeError(
147
+ 'canonicalize: objects with a toJSON() method (e.g. Date) are not accepted; ' +
148
+ 'serialize them to a JSON value (e.g. an ISO string) before canonicalizing',
149
+ )
150
+ }
151
+
152
+ const obj = value as Record<string, unknown>
153
+ // Object properties whose value is `undefined` are OMITTED (matches
154
+ // JSON.stringify and the JSON data model). This is the one omission we honor;
155
+ // an undefined ARRAY element throws (above), because dropping it would shift
156
+ // indices and silently change meaning.
157
+ const keys = Object.keys(obj)
158
+ .filter((k) => obj[k] !== undefined)
159
+ .sort()
160
+ return (
161
+ '{' +
162
+ keys
163
+ .map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k]))
164
+ .join(',') +
165
+ '}'
166
+ )
167
+ }
package/src/ed25519.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @synoi/sraid — ed25519.ts
3
+ *
4
+ * Native Ed25519 verification via node:crypto (OpenSSL). Verifying RFC 8032
5
+ * Ed25519 signatures with the platform crypto is materially faster than a
6
+ * pure-JS implementation (~30x on the verify op) and produces the same
7
+ * accept/reject result for all standard signatures.
8
+ *
9
+ * NOTE on verification semantics: node:crypto / OpenSSL uses RFC 8032
10
+ * cofactored verification. Some pure-JS libraries (e.g. @noble) default to
11
+ * ZIP-215 batch-compatible rules, which accept a slightly larger set of
12
+ * non-canonical edge-case points. The two agree on every well-formed
13
+ * signature; they can differ only on deliberately malformed/malleable points
14
+ * that real signers never produce. SynOI signs canonically, so this is a
15
+ * tightening, not a behavior change, but it is called out for maintainers.
16
+ *
17
+ * The public API takes a RAW 32-byte Ed25519 public key; this module wraps it
18
+ * in the fixed SPKI DER prefix to build a KeyObject.
19
+ */
20
+
21
+ import { createPublicKey, verify as nodeVerify, type KeyObject } from 'node:crypto'
22
+
23
+ import { BoundedKeyCache, KEY_CACHE_MAX } from './internal/key-cache.js'
24
+
25
+ // Fixed DER prefix for an Ed25519 SubjectPublicKeyInfo (RFC 8410):
26
+ // SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING (32 raw bytes) }
27
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
28
+
29
+ // Small bounded LRU: building a KeyObject parses DER, so cache by raw-key hex.
30
+ // Bounded so an attacker streaming distinct keys cannot grow it without limit.
31
+ const keyCache = new BoundedKeyCache<KeyObject>(KEY_CACHE_MAX)
32
+
33
+ function keyObjectFromRaw(raw: Uint8Array): KeyObject {
34
+ if (raw.length !== 32) throw new Error('ed25519 public key must be 32 bytes')
35
+ const hex = Buffer.from(raw).toString('hex')
36
+ let ko = keyCache.get(hex)
37
+ if (!ko) {
38
+ const der = Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)])
39
+ ko = createPublicKey({ key: der, format: 'der', type: 'spki' })
40
+ keyCache.set(hex, ko)
41
+ }
42
+ return ko
43
+ }
44
+
45
+ /**
46
+ * Verify a raw 64-byte Ed25519 signature over `message` against a raw 32-byte
47
+ * public key. Returns false (never throws) on any malformed input or
48
+ * verification failure, so callers get a clean boolean.
49
+ */
50
+ export function verifyEd25519(
51
+ signature: Uint8Array,
52
+ message: Uint8Array,
53
+ publicKeyRaw: Uint8Array,
54
+ ): boolean {
55
+ try {
56
+ return nodeVerify(null, message, keyObjectFromRaw(publicKeyRaw), signature)
57
+ } catch {
58
+ return false
59
+ }
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @synoi/sraid — public surface.
3
+ *
4
+ * SRAID (Self-Routing Addressable Identity Data) is L0 of the SynOI SRAID Stack:
5
+ *
6
+ * L0 SRAID ← this package
7
+ * L1 Vault / Resolver
8
+ * L2 Inference Broker / Resonance
9
+ * L3 GAP (Governed Action Protocol)
10
+ * L4 Apps
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.
15
+ */
16
+
17
+ // Types
18
+ export type {
19
+ AttestationEnvelope,
20
+ AttestationSignature,
21
+ AuthorityBlock,
22
+ AuthorityDecision,
23
+ CDRO,
24
+ LineageLink,
25
+ LinkRel,
26
+ SRO,
27
+ SROBody,
28
+ SensitivityTier,
29
+ SignatureEnvelope,
30
+ } from './types.js'
31
+
32
+ // Canonical serializer — byte-identical to synoi-gateway + @synoi/vault.
33
+ export { canonicalize } from './canonicalize.js'
34
+
35
+ // OID computation — value-level and full-CDRO content-core helpers.
36
+ export { oidOf, oidOfCanonical, cdroOid, cdroContentCore, CDRO_ENVELOPE_FIELDS } from './oid.js'
37
+
38
+ // Hybrid Ed25519 + ML-DSA-65 signature verification (LEGACY bare-bytes path).
39
+ export {
40
+ verifySignature,
41
+ type VerifySignatureInput,
42
+ type VerifySignatureResult,
43
+ } from './signature.js'
44
+
45
+ // ML-DSA-65 verification with a native node:crypto fast path (OpenSSL 3.5+ /
46
+ // Node 24+) and a transparent @noble fallback on older runtimes. Both paths
47
+ // are byte-identical; `isNativeMlDsaAvailable()` reports which is active.
48
+ export { verifyMlDsa65, isNativeMlDsaAvailable } from './mldsa.js'
49
+
50
+ // Coordinated key-cache revocation. The internal bounded LRU key caches
51
+ // (ed25519 + ml-dsa-65) are keyed by lowercase hex of the RAW public-key
52
+ // bytes; `evictKeyFromCaches(keyId)` drops that entry from every cache so a
53
+ // rotated or compromised key cannot be served stale on a later verify.
54
+ export { evictKeyFromCaches } from './internal/key-cache.js'
55
+
56
+ // L2 DSSE attestation — PAE type-binding, hybrid Ed25519 + ML-DSA-65 both
57
+ // required. The preferred signing path (replaces the legacy bare-bytes
58
+ // SignatureEnvelope; closes the cross-type confusion gap SRAID F7 / A4).
59
+ export {
60
+ verifyAttestation,
61
+ pae,
62
+ ALG_ED25519,
63
+ ALG_ML_DSA_65,
64
+ type VerifyAttestationInput,
65
+ type VerifyAttestationResult,
66
+ } from './attestation.js'
67
+
68
+ // L3 lineage (Merkle-DAG) — unify supersedes/SRO/prev; latest-wins resolution.
69
+ export {
70
+ lineageLinks,
71
+ supersededOids,
72
+ latestWins,
73
+ type LatestWinsResult,
74
+ } from './lineage.js'
75
+
76
+ // L4 propagating sensitivity — coarse, opaque tier + monotone max() carry-forward.
77
+ export {
78
+ SENSITIVITY_TIERS,
79
+ SENSITIVITY_DEFAULT,
80
+ isSensitivityTier,
81
+ sensitivityRank,
82
+ sensitivityMax,
83
+ sensitivityCarryForward,
84
+ sensitivityMonotoneCheck,
85
+ } from './sensitivity.js'
86
+
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'
108
+
109
+ // Shape validators.
110
+ export {
111
+ validateCdro,
112
+ validateSro,
113
+ validateSignatureEnvelope,
114
+ validateAttestationEnvelope,
115
+ validateAuthorityBlock,
116
+ validateLineageLink,
117
+ type ValidationResult,
118
+ } from './validate.js'
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @synoi/sraid — internal/base64.ts
3
+ *
4
+ * Strict standard-base64 (RFC 4648 §4, with `=` padding) decode.
5
+ *
6
+ * INTERNAL: not exported from index.ts.
7
+ *
8
+ * Why strict: `Buffer.from(s, 'base64')` silently TRUNCATES at the first byte
9
+ * outside the alphabet and tolerates missing/extra padding. On a signature-
10
+ * verification path that is dangerous — a malformed envelope must be rejected
11
+ * loudly, not decoded to a short prefix that then "verifies" against something
12
+ * unintended. `decodeBase64Strict` validates the alphabet, length, and padding
13
+ * BEFORE decoding and throws on any deviation.
14
+ *
15
+ * Canonical inputs carry no surrounding whitespace, so no trim is applied;
16
+ * leading/trailing whitespace is itself a rejection.
17
+ */
18
+
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}$/
22
+
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
+ }
36
+
37
+ /**
38
+ * Strictly decode standard base64 to raw bytes. Throws
39
+ * Error('base64-malformed') on any non-conforming input.
40
+ */
41
+ export function decodeBase64Strict(s: string): Uint8Array {
42
+ assertBase64(s)
43
+ return new Uint8Array(Buffer.from(s, 'base64'))
44
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @synoi/sraid — internal/key-cache.ts
3
+ *
4
+ * Bounded, insertion-order LRU for cached KeyObjects, plus a registry so a
5
+ * single revocation call can fan out across every live cache.
6
+ *
7
+ * INTERNAL: not exported from index.ts. Only `evictKeyFromCaches` is public.
8
+ *
9
+ * Why a hand-rolled LRU and not `lru-cache`: this package is deliberately
10
+ * zero-runtime-dependency (only @noble/* dev/peer surface). Pulling an npm LRU
11
+ * to bound a key cache would widen the supply-chain attack surface of the exact
12
+ * L0 library whose value is a minimal, auditable core. A ~40-line insertion-
13
+ * order LRU costs nothing and keeps that posture.
14
+ *
15
+ * The cache key is the lowercase hex of the RAW public-key bytes — the same
16
+ * string mldsa.ts and ed25519.ts already use as their Map key. It is NOT an
17
+ * OID, DID, or kid. ed25519 keys (32B hex) and ml-dsa-65 keys (1952B hex)
18
+ * never collide, so the two caches share one key namespace safely and a
19
+ * cross-cache evict is a harmless no-op when the key is absent.
20
+ */
21
+
22
+ /** Per-cache capacity. At 1952 bytes/ml-dsa-key this bounds raw-key-equivalent
23
+ * residency near ~2MB plus KeyObject overhead — bounded and safe. */
24
+ export const KEY_CACHE_MAX = 1000
25
+
26
+ /** Registry of every live cache, for coordinated eviction. */
27
+ const registry: BoundedKeyCache<unknown>[] = []
28
+
29
+ /**
30
+ * Insertion-order LRU. Map preserves insertion order, so the oldest live entry
31
+ * is always `keys().next()`. A `get` hit re-inserts (delete + set) to promote
32
+ * the entry to most-recently-used. `set` evicts the oldest when at capacity.
33
+ */
34
+ export class BoundedKeyCache<V> {
35
+ private readonly map = new Map<string, V>()
36
+
37
+ constructor(readonly max: number = KEY_CACHE_MAX) {
38
+ registry.push(this as BoundedKeyCache<unknown>)
39
+ }
40
+
41
+ get size(): number {
42
+ return this.map.size
43
+ }
44
+
45
+ get(key: string): V | undefined {
46
+ const v = this.map.get(key)
47
+ if (v === undefined) return undefined
48
+ // Promote to most-recently-used.
49
+ this.map.delete(key)
50
+ this.map.set(key, v)
51
+ return v
52
+ }
53
+
54
+ set(key: string, value: V): void {
55
+ // If present, drop first so re-insert lands at the MRU end.
56
+ if (this.map.has(key)) this.map.delete(key)
57
+ else if (this.map.size >= this.max) {
58
+ const oldest = this.map.keys().next().value
59
+ if (oldest !== undefined) this.map.delete(oldest)
60
+ }
61
+ this.map.set(key, value)
62
+ }
63
+
64
+ delete(key: string): boolean {
65
+ return this.map.delete(key)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Evict a key from every live cache. Use for coordinated key revocation: when a
71
+ * signing key is rotated or compromised, drop any cached KeyObject for it so a
72
+ * later verify cannot be served a stale entry.
73
+ *
74
+ * @param keyId lowercase hex of the RAW public-key bytes (the cache key). Not
75
+ * an OID/DID/kid. Deleting an absent key is a harmless no-op.
76
+ */
77
+ export function evictKeyFromCaches(keyId: string): void {
78
+ for (const cache of registry) cache.delete(keyId)
79
+ }
package/src/lineage.ts ADDED
Binary file
package/src/mldsa.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @synoi/sraid — mldsa.ts
3
+ *
4
+ * ML-DSA-65 (FIPS 204) verification with a native fast path.
5
+ *
6
+ * When the runtime ships OpenSSL 3.5+ (Node 24+), node:crypto exposes a
7
+ * native `ml-dsa-65` key type whose verify is materially faster than the
8
+ * pure-JS @noble/post-quantum implementation: ~207µs vs ~1.6ms verify, a
9
+ * ~7.8x op-level speedup measured on Node 24.16.0 / OpenSSL 3.5.6
10
+ * (bench/mldsa-verify.mjs).
11
+ *
12
+ * This module feature-detects native ML-DSA-65 support ONCE at first use and
13
+ * routes verification accordingly:
14
+ * • native available → node:crypto (OpenSSL)
15
+ * • native absent → @noble/post-quantum fallback
16
+ *
17
+ * Both paths are byte-for-byte interoperable: a signature minted by either
18
+ * implementation verifies identically under the other, and tampered or
19
+ * wrong-key inputs fail under both (see test/mldsa-kat.test.ts cross-impl
20
+ * known-answer vectors). The native path is therefore a transparent
21
+ * performance swap, NOT a behavior change. Node < 24 keeps working unchanged.
22
+ *
23
+ * The public API takes a RAW ML-DSA-65 public key (1952 bytes) and a RAW
24
+ * signature (3309 bytes), matching the @noble surface. The native path wraps
25
+ * the raw key in the fixed SubjectPublicKeyInfo DER prefix to build a
26
+ * KeyObject; @noble consumes the raw bytes directly.
27
+ */
28
+
29
+ import { createPublicKey, verify as nodeVerify, type KeyObject } from 'node:crypto'
30
+
31
+ import { webcrypto } from 'node:crypto'
32
+ // Node 18 doesn't expose globalThis.crypto.getRandomValues by default, and the
33
+ // @noble libraries expect it during module initialization. Idempotent polyfill
34
+ // — MUST run BEFORE the @noble/post-quantum import below so ml_dsa65's
35
+ // module-init sees a populated globalThis.crypto.
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ if (!(globalThis as any).crypto) (globalThis as any).crypto = webcrypto
38
+
39
+ import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
40
+
41
+ import { BoundedKeyCache, KEY_CACHE_MAX } from './internal/key-cache.js'
42
+
43
+ // Fixed DER prefix for an ML-DSA-65 SubjectPublicKeyInfo
44
+ // (draft-ietf-lamps-dilithium-certificates):
45
+ // SEQUENCE {
46
+ // SEQUENCE { OID 2.16.840.1.101.3.4.3.18 (id-ml-dsa-65) }
47
+ // BIT STRING (0 unused bits || 1952 raw public-key bytes)
48
+ // }
49
+ // The only variable part is the 1952-byte key, so the 22-byte header is
50
+ // constant for every ML-DSA-65 key.
51
+ const MLDSA65_SPKI_PREFIX = Buffer.from(
52
+ '308207b2300b0609608648016503040312038207a100',
53
+ 'hex',
54
+ )
55
+ const MLDSA65_PUBKEY_LEN = 1952
56
+
57
+ // One-time capability detection. `undefined` = not yet probed.
58
+ let nativeAvailable: boolean | undefined
59
+
60
+ /**
61
+ * Detect (once, then cache) whether this runtime's node:crypto can build a
62
+ * native ML-DSA-65 public key. We probe by constructing a KeyObject from a
63
+ * structurally-valid dummy SPKI: OpenSSL 3.5+ accepts it and reports
64
+ * asymmetricKeyType === 'ml-dsa-65'; older OpenSSL throws ("Failed to read
65
+ * asymmetric key"). The probe does NOT verify anything, so a dummy key body
66
+ * is fine — only the algorithm support is being detected.
67
+ */
68
+ function detectNative(): boolean {
69
+ if (nativeAvailable !== undefined) return nativeAvailable
70
+ try {
71
+ const dummy = Buffer.concat([MLDSA65_SPKI_PREFIX, Buffer.alloc(MLDSA65_PUBKEY_LEN)])
72
+ const ko = createPublicKey({ key: dummy, format: 'der', type: 'spki' })
73
+ // @types/node ^20 predates the ml-dsa-* key types, so widen to string.
74
+ nativeAvailable = (ko.asymmetricKeyType as string) === 'ml-dsa-65'
75
+ } catch {
76
+ nativeAvailable = false
77
+ }
78
+ return nativeAvailable
79
+ }
80
+
81
+ /**
82
+ * True when this runtime verifies ML-DSA-65 via native node:crypto (OpenSSL
83
+ * 3.5+, Node 24+). False when it falls back to @noble/post-quantum. Exposed
84
+ * for benchmarks, diagnostics, and tests that must exercise both paths.
85
+ */
86
+ export function isNativeMlDsaAvailable(): boolean {
87
+ return detectNative()
88
+ }
89
+
90
+ // Small bounded LRU: building a KeyObject parses DER, so cache by raw-key hex.
91
+ // Bounded so an attacker streaming distinct keys cannot grow it without limit.
92
+ const keyCache = new BoundedKeyCache<KeyObject>(KEY_CACHE_MAX)
93
+
94
+ function keyObjectFromRaw(raw: Uint8Array): KeyObject {
95
+ if (raw.length !== MLDSA65_PUBKEY_LEN) {
96
+ throw new Error(`ml-dsa-65 public key must be ${MLDSA65_PUBKEY_LEN} bytes`)
97
+ }
98
+ const hex = Buffer.from(raw).toString('hex')
99
+ let ko = keyCache.get(hex)
100
+ if (!ko) {
101
+ const der = Buffer.concat([MLDSA65_SPKI_PREFIX, Buffer.from(raw)])
102
+ ko = createPublicKey({ key: der, format: 'der', type: 'spki' })
103
+ keyCache.set(hex, ko)
104
+ }
105
+ return ko
106
+ }
107
+
108
+ /**
109
+ * Verify a raw ML-DSA-65 signature over `message` against a raw 1952-byte
110
+ * public key. Uses the native node:crypto path when available, otherwise
111
+ * @noble/post-quantum. Returns false (never throws) on any malformed input or
112
+ * verification failure, so callers get a clean boolean.
113
+ */
114
+ export function verifyMlDsa65(
115
+ signature: Uint8Array,
116
+ message: Uint8Array,
117
+ publicKeyRaw: Uint8Array,
118
+ ): boolean {
119
+ if (detectNative()) {
120
+ try {
121
+ return nodeVerify(null, message, keyObjectFromRaw(publicKeyRaw), signature)
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+ try {
127
+ return ml_dsa65.verify(signature, message, publicKeyRaw)
128
+ } catch {
129
+ return false
130
+ }
131
+ }