@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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — attestation.ts
|
|
3
|
+
*
|
|
4
|
+
* L2 attestation layer: a DSSE (Dead Simple Signing Envelope) profile that
|
|
5
|
+
* carries one or more detached signatures over a SRAID object's canonical
|
|
6
|
+
* payload, with the payload TYPE structurally bound into the signed bytes.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists (SRAID F7 / Adversary A4, SRAID_FOUNDATION_PUNCHLIST A3,
|
|
9
|
+
* OBJECT_MODEL_CLEANSHEET §3.2): the legacy `SignatureEnvelope` signs the
|
|
10
|
+
* bare canonical bytes with NO payload-type binding. Because every SRAID
|
|
11
|
+
* object shape (decision receipt, capability grant, memory record, identity
|
|
12
|
+
* disclosure, supply-chain attestation, …) is signed identically over its
|
|
13
|
+
* canonical bytes, a signature minted for one type could be replayed as a
|
|
14
|
+
* valid signature for a different type whose canonical bytes happen to match
|
|
15
|
+
* — a cross-type / confused-deputy hazard. DSSE closes this by signing the
|
|
16
|
+
* Pre-Authentication Encoding (PAE), which prepends the payloadType and the
|
|
17
|
+
* lengths of both fields, so the type is part of what is signed.
|
|
18
|
+
*
|
|
19
|
+
* SynOI policy (the AND rule): a SRAID attestation MUST carry BOTH an
|
|
20
|
+
* `ed25519` and an `ml-dsa-65` signature over the SAME PAE, and the verifier
|
|
21
|
+
* here requires BOTH to verify before returning `valid: true`. DSSE itself
|
|
22
|
+
* is an OR-of-signatures envelope; the both-required rule lives in this
|
|
23
|
+
* verifier and in the SRAID spec, not in DSSE. This preserves the hybrid
|
|
24
|
+
* classical + post-quantum property of the legacy scheme.
|
|
25
|
+
*
|
|
26
|
+
* Profile: JSON (`payloadType` is a media type string; `payload` is the
|
|
27
|
+
* canonical UTF-8 string). A future CBOR profile would use COSE (RFC 9052);
|
|
28
|
+
* it is reserved, not implemented here.
|
|
29
|
+
*
|
|
30
|
+
* Identity stability: signatures live in a detached `signatures[]` array and
|
|
31
|
+
* are NEVER part of the OID hash input (see `cdroContentCore` in oid.ts), so
|
|
32
|
+
* adding, rotating, or replacing a signature never changes the object's OID.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { verifyEd25519 } from './ed25519.js'
|
|
36
|
+
import { decodeBase64Strict } from './internal/base64.js'
|
|
37
|
+
import { verifyMlDsa65 } from './mldsa.js'
|
|
38
|
+
import type { AttestationEnvelope, AttestationSignature } from './types.js'
|
|
39
|
+
|
|
40
|
+
/** The single supported algorithm identifiers for the JSON profile. */
|
|
41
|
+
export const ALG_ED25519 = 'ed25519'
|
|
42
|
+
export const ALG_ML_DSA_65 = 'ml-dsa-65'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The DSSE Pre-Authentication Encoding (PAE).
|
|
46
|
+
*
|
|
47
|
+
* Per the DSSE spec:
|
|
48
|
+
*
|
|
49
|
+
* PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
|
|
50
|
+
*
|
|
51
|
+
* where:
|
|
52
|
+
* - SP is a single ASCII space (0x20),
|
|
53
|
+
* - LEN(x) is the ASCII-decimal byte length of x's UTF-8 encoding,
|
|
54
|
+
* - type and body are the raw UTF-8 bytes (NOT base64).
|
|
55
|
+
*
|
|
56
|
+
* Binding the payloadType (and both lengths) into the signed bytes is what
|
|
57
|
+
* makes a signature non-transferable across object types. Returns the raw
|
|
58
|
+
* bytes to be signed/verified.
|
|
59
|
+
*/
|
|
60
|
+
export function pae(payloadType: string, payload: string | Uint8Array): Uint8Array {
|
|
61
|
+
const enc = new TextEncoder()
|
|
62
|
+
const typeBytes = enc.encode(payloadType)
|
|
63
|
+
const bodyBytes = typeof payload === 'string' ? enc.encode(payload) : payload
|
|
64
|
+
|
|
65
|
+
const prefix = enc.encode(
|
|
66
|
+
`DSSEv1 ${typeBytes.length} ${payloadType} ${bodyBytes.length} `,
|
|
67
|
+
)
|
|
68
|
+
const out = new Uint8Array(prefix.length + bodyBytes.length)
|
|
69
|
+
out.set(prefix, 0)
|
|
70
|
+
out.set(bodyBytes, prefix.length)
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface VerifyAttestationInput {
|
|
75
|
+
/**
|
|
76
|
+
* The DSSE attestation envelope to verify. Its `payloadType` is bound into
|
|
77
|
+
* the PAE; its `payload` is the canonical UTF-8 string that was signed.
|
|
78
|
+
*/
|
|
79
|
+
envelope: AttestationEnvelope
|
|
80
|
+
/** Raw 32-byte Ed25519 public key. */
|
|
81
|
+
ed25519_pub: Uint8Array
|
|
82
|
+
/** Raw ML-DSA-65 public key bytes. */
|
|
83
|
+
ml_dsa_pub: Uint8Array
|
|
84
|
+
/**
|
|
85
|
+
* Optional. If supplied, the verifier asserts the envelope's `payloadType`
|
|
86
|
+
* equals this value before verifying signatures — an explicit type-pinning
|
|
87
|
+
* check on top of the structural PAE binding. A mismatch fails with
|
|
88
|
+
* `payload-type-mismatch`.
|
|
89
|
+
*/
|
|
90
|
+
expectedPayloadType?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface VerifyAttestationResult {
|
|
94
|
+
/** True only when BOTH required signatures verified over the same PAE. */
|
|
95
|
+
valid: boolean
|
|
96
|
+
/**
|
|
97
|
+
* Human-readable failure reasons. Empty when valid. Possible values:
|
|
98
|
+
* 'envelope-malformed', 'payload-type-mismatch', 'missing-ed25519',
|
|
99
|
+
* 'missing-ml-dsa-65', 'ed25519-malformed', 'ml-dsa-malformed',
|
|
100
|
+
* 'ed25519-invalid', 'ml-dsa-invalid'.
|
|
101
|
+
*/
|
|
102
|
+
reasons: string[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Verify a hybrid DSSE attestation envelope. Returns `valid: true` only when
|
|
107
|
+
* the envelope carries BOTH an `ed25519` and an `ml-dsa-65` signature and
|
|
108
|
+
* BOTH verify against the supplied public keys over `PAE(payloadType,
|
|
109
|
+
* payload)`. The payloadType is bound into the signed bytes, so a signature
|
|
110
|
+
* minted for a different payloadType will not verify.
|
|
111
|
+
*/
|
|
112
|
+
export function verifyAttestation(input: VerifyAttestationInput): VerifyAttestationResult {
|
|
113
|
+
const reasons: string[] = []
|
|
114
|
+
|
|
115
|
+
const env = input.envelope
|
|
116
|
+
if (
|
|
117
|
+
env === null ||
|
|
118
|
+
typeof env !== 'object' ||
|
|
119
|
+
typeof env.payloadType !== 'string' ||
|
|
120
|
+
env.payloadType.length === 0 ||
|
|
121
|
+
typeof env.payload !== 'string' ||
|
|
122
|
+
!Array.isArray(env.signatures)
|
|
123
|
+
) {
|
|
124
|
+
return { valid: false, reasons: ['envelope-malformed'] }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
input.expectedPayloadType !== undefined &&
|
|
129
|
+
env.payloadType !== input.expectedPayloadType
|
|
130
|
+
) {
|
|
131
|
+
return { valid: false, reasons: ['payload-type-mismatch'] }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The signed bytes: PAE binds payloadType + payload together.
|
|
135
|
+
const message = pae(env.payloadType, env.payload)
|
|
136
|
+
|
|
137
|
+
// Find the required hybrid pair. The AND policy: both must be present.
|
|
138
|
+
const edEntry = findSig(env.signatures, ALG_ED25519)
|
|
139
|
+
const mlEntry = findSig(env.signatures, ALG_ML_DSA_65)
|
|
140
|
+
|
|
141
|
+
if (!edEntry) reasons.push('missing-ed25519')
|
|
142
|
+
if (!mlEntry) reasons.push('missing-ml-dsa-65')
|
|
143
|
+
|
|
144
|
+
let edOk = false
|
|
145
|
+
let mlOk = false
|
|
146
|
+
|
|
147
|
+
if (edEntry) {
|
|
148
|
+
let edSig: Uint8Array | null = null
|
|
149
|
+
try {
|
|
150
|
+
edSig = fromBase64(edEntry.sig)
|
|
151
|
+
} catch {
|
|
152
|
+
reasons.push('ed25519-malformed')
|
|
153
|
+
}
|
|
154
|
+
if (edSig) {
|
|
155
|
+
try {
|
|
156
|
+
edOk = verifyEd25519(edSig, message, input.ed25519_pub)
|
|
157
|
+
} catch {
|
|
158
|
+
edOk = false
|
|
159
|
+
}
|
|
160
|
+
if (!edOk) reasons.push('ed25519-invalid')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (mlEntry) {
|
|
165
|
+
let mlSig: Uint8Array | null = null
|
|
166
|
+
try {
|
|
167
|
+
mlSig = fromBase64(mlEntry.sig)
|
|
168
|
+
} catch {
|
|
169
|
+
reasons.push('ml-dsa-malformed')
|
|
170
|
+
}
|
|
171
|
+
if (mlSig) {
|
|
172
|
+
try {
|
|
173
|
+
mlOk = verifyMlDsa65(mlSig, message, input.ml_dsa_pub)
|
|
174
|
+
} catch {
|
|
175
|
+
mlOk = false
|
|
176
|
+
}
|
|
177
|
+
if (!mlOk) reasons.push('ml-dsa-invalid')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { valid: edOk && mlOk && !!edEntry && !!mlEntry, reasons }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function findSig(
|
|
187
|
+
sigs: readonly AttestationSignature[],
|
|
188
|
+
alg: string,
|
|
189
|
+
): AttestationSignature | undefined {
|
|
190
|
+
for (const s of sigs) {
|
|
191
|
+
if (s && typeof s === 'object' && s.alg === alg && typeof s.sig === 'string') {
|
|
192
|
+
return s
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Strict standard base64. Throws Error('base64-malformed') on any deviation
|
|
199
|
+
// (illegal char, bad length, bad padding) rather than silently truncating.
|
|
200
|
+
// Both call sites wrap this in try/catch and map the throw to a *-malformed
|
|
201
|
+
// reason, so verifyAttestation never throws.
|
|
202
|
+
function fromBase64(s: string): Uint8Array {
|
|
203
|
+
return decodeBase64Strict(s)
|
|
204
|
+
}
|