@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.
- package/LICENSE +21 -0
- package/PROJECTION_SPEC.md +245 -0
- package/README.md +149 -0
- package/SPEC.md +216 -0
- package/dist/attestation.d.ts +92 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +155 -0
- package/dist/attestation.js.map +1 -0
- package/dist/authority.d.ts +351 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +563 -0
- package/dist/authority.js.map +1 -0
- package/dist/canonicalize.d.ts +70 -0
- package/dist/canonicalize.d.ts.map +1 -0
- package/dist/canonicalize.js +151 -0
- package/dist/canonicalize.js.map +1 -0
- package/dist/ed25519.d.ts +26 -0
- package/dist/ed25519.d.ts.map +1 -0
- package/dist/ed25519.js +53 -0
- package/dist/ed25519.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/base64.d.ts +28 -0
- package/dist/internal/base64.d.ts.map +1 -0
- package/dist/internal/base64.js +45 -0
- package/dist/internal/base64.js.map +1 -0
- package/dist/internal/key-cache.d.ts +47 -0
- package/dist/internal/key-cache.d.ts.map +1 -0
- package/dist/internal/key-cache.js +77 -0
- package/dist/internal/key-cache.js.map +1 -0
- package/dist/lineage.d.ts +104 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +0 -0
- package/dist/lineage.js.map +1 -0
- package/dist/mldsa.d.ts +41 -0
- package/dist/mldsa.d.ts.map +1 -0
- package/dist/mldsa.js +119 -0
- package/dist/mldsa.js.map +1 -0
- package/dist/oid.d.ts +109 -0
- package/dist/oid.d.ts.map +1 -0
- package/dist/oid.js +140 -0
- package/dist/oid.js.map +1 -0
- package/dist/sensitivity.d.ts +104 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js +117 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/signature.d.ts +46 -0
- package/dist/signature.d.ts.map +1 -0
- package/dist/signature.js +89 -0
- package/dist/signature.js.map +1 -0
- package/dist/types.d.ts +313 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +88 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +339 -0
- package/dist/validate.js.map +1 -0
- package/package.json +69 -0
- package/src/attestation.ts +204 -0
- package/src/authority.ts +849 -0
- package/src/canonicalize.ts +167 -0
- package/src/ed25519.ts +60 -0
- package/src/index.ts +118 -0
- package/src/internal/base64.ts +44 -0
- package/src/internal/key-cache.ts +79 -0
- package/src/lineage.ts +0 -0
- package/src/mldsa.ts +131 -0
- package/src/oid.ts +146 -0
- package/src/sensitivity.ts +154 -0
- package/src/signature.ts +119 -0
- package/src/types.ts +351 -0
- 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
|
+
}
|