@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,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
+ }