@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
package/src/oid.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — oid.ts
|
|
3
|
+
*
|
|
4
|
+
* OID (Object IDentifier) computation. An OID is a content-addressed
|
|
5
|
+
* identifier for a CDRO object:
|
|
6
|
+
*
|
|
7
|
+
* OID = "sha256:" + hex(sha256(canonicalize(content)))
|
|
8
|
+
*
|
|
9
|
+
* The hash input is the canonical form of the OBJECT MINUS the six detached
|
|
10
|
+
* signature / envelope fields (see `CDRO_ENVELOPE_FIELDS`). Higher-layer code
|
|
11
|
+
* (e.g. GAP) typically passes the object's `body` directly; this function
|
|
12
|
+
* accepts any value and canonicalizes it without further interpretation, so
|
|
13
|
+
* callers stay in control of what enters the hash.
|
|
14
|
+
*
|
|
15
|
+
* This module is the SINGLE NORMATIVE SOURCE of the CDRO OID projection
|
|
16
|
+
* (ADR_019). The strip-set and number rule are specified in prose in
|
|
17
|
+
* PROJECTION_SPEC.md; all other surfaces derive from here.
|
|
18
|
+
*
|
|
19
|
+
* This matches the gateway's `computeGapOid` (src/gap/oid.ts) and
|
|
20
|
+
* `payloadOid` (src/inference/receipts.ts) byte-for-byte.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
import { canonicalize } from './canonicalize.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute an OID over an arbitrary canonical-compatible value.
|
|
28
|
+
*
|
|
29
|
+
* oidOf({ a: 1, b: 2 }) === oidOf({ b: 2, a: 1 })
|
|
30
|
+
*
|
|
31
|
+
* Returns `sha256:` followed by 64 lowercase hex characters.
|
|
32
|
+
*
|
|
33
|
+
* Uses the platform SHA-256 (node:crypto / OpenSSL), which is byte-identical
|
|
34
|
+
* to any conformant SHA-256 and materially faster than a pure-JS hash. The
|
|
35
|
+
* output contract is unchanged.
|
|
36
|
+
*/
|
|
37
|
+
export function oidOf(canonical: unknown): string {
|
|
38
|
+
const bytes = new TextEncoder().encode(canonicalize(canonical))
|
|
39
|
+
return 'sha256:' + createHash('sha256').update(bytes).digest('hex')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compute an OID directly from already-canonicalized bytes. Useful when
|
|
44
|
+
* the caller has produced the canonical string itself (for example, when
|
|
45
|
+
* the same bytes will also feed into a signature) and wants to avoid
|
|
46
|
+
* canonicalizing twice.
|
|
47
|
+
*/
|
|
48
|
+
export function oidOfCanonical(canonical: string | Uint8Array): string {
|
|
49
|
+
const bytes =
|
|
50
|
+
typeof canonical === 'string'
|
|
51
|
+
? new TextEncoder().encode(canonical)
|
|
52
|
+
: canonical
|
|
53
|
+
return 'sha256:' + createHash('sha256').update(bytes).digest('hex')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The detached-signature / envelope fields removed by `cdroContentCore`
|
|
58
|
+
* before hashing. This is the SINGLE NORMATIVE strip-set for the CDRO OID
|
|
59
|
+
* projection (ADR_019 decision 1); every other surface (GAP SDKs, the
|
|
60
|
+
* gateway signer, IMPLEMENTING.md) derives from THIS set, never re-lists it.
|
|
61
|
+
*
|
|
62
|
+
* The set is defined SEMANTICALLY: it is "every field produced BY the signer
|
|
63
|
+
* after canonicalization, plus the OID output itself." Concretely:
|
|
64
|
+
*
|
|
65
|
+
* oid — the projection OUTPUT (cannot be an input to itself).
|
|
66
|
+
* signature — legacy hybrid SignatureEnvelope (attaches after hash).
|
|
67
|
+
* ml_dsa_signature — detached PQ signature (attaches after hash).
|
|
68
|
+
* signature_key_id — signer-stamped key id (produced by the signer).
|
|
69
|
+
* signature_algorithm — signer-stamped alg id (produced by the signer).
|
|
70
|
+
* attestation — DSSE AttestationEnvelope (attaches after hash).
|
|
71
|
+
*
|
|
72
|
+
* EVERYTHING ELSE IS KEPT and hashed into the OID, including in particular:
|
|
73
|
+
* - `gap_version` — IN identity so a protocol downgrade is OID-detectable.
|
|
74
|
+
* - `supersedes` — IN identity because the SRAID Merkle-DAG head-proves-
|
|
75
|
+
* history property requires every lineage edge inside the
|
|
76
|
+
* hash. Superseding mints a NEW object; it never mutates
|
|
77
|
+
* the old one's bytes, so keeping it here is safe and
|
|
78
|
+
* makes the lineage edge tamper-evident.
|
|
79
|
+
* - `type`, `sraid_version`, `tenant_id`, `created_at_ms`, `created_by`,
|
|
80
|
+
* `body`, `authority`, `sensitivity`, `prev`, `links`, and any other
|
|
81
|
+
* content field.
|
|
82
|
+
*
|
|
83
|
+
* This is the ONE projection that yields the SAME OID whether the object is
|
|
84
|
+
* pre- or post-attestation: attaching an `attestation` (or `signature`,
|
|
85
|
+
* `ml_dsa_signature`, `signature_key_id`, `signature_algorithm`) after hashing
|
|
86
|
+
* is stripped back out here, so `cdroOid(obj)` is invariant across signing.
|
|
87
|
+
*
|
|
88
|
+
* It is FROZEN so no caller can mutate the normative set at runtime.
|
|
89
|
+
*/
|
|
90
|
+
export const CDRO_ENVELOPE_FIELDS: readonly string[] = Object.freeze([
|
|
91
|
+
'oid',
|
|
92
|
+
'signature',
|
|
93
|
+
'ml_dsa_signature',
|
|
94
|
+
'signature_key_id',
|
|
95
|
+
'signature_algorithm',
|
|
96
|
+
'attestation',
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
const CDRO_ENVELOPE_FIELD_SET: ReadonlySet<string> = new Set(CDRO_ENVELOPE_FIELDS)
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the OID content core of a full CDRO: the object with EXACTLY the six
|
|
103
|
+
* detached-signature / envelope fields in `CDRO_ENVELOPE_FIELDS` removed at
|
|
104
|
+
* the top level, and everything else kept.
|
|
105
|
+
*
|
|
106
|
+
* This is the mechanism that makes the L4 `authority` block, the L3 lineage
|
|
107
|
+
* edges (`prev`, `links`, `supersedes`), the propagating `sensitivity` tier,
|
|
108
|
+
* and `gap_version` tamper-evident: they are hashed into identity by
|
|
109
|
+
* construction, so a field cannot be added, stripped, re-pointed, or
|
|
110
|
+
* downgraded without producing a different OID (and invalidating the
|
|
111
|
+
* signature, which is computed over these same bytes). Because `prev`/`links`/
|
|
112
|
+
* `supersedes` OIDs are inside the hash, a node's OID transitively commits its
|
|
113
|
+
* whole reachable history (the Merkle-DAG "head proves history" property).
|
|
114
|
+
*
|
|
115
|
+
* Returns a plain object suitable for `canonicalize` / `oidOf`.
|
|
116
|
+
*/
|
|
117
|
+
export function cdroContentCore(cdro: unknown): Record<string, unknown> {
|
|
118
|
+
if (cdro === null || typeof cdro !== 'object' || Array.isArray(cdro)) {
|
|
119
|
+
throw new TypeError('cdroContentCore: argument must be a CDRO object')
|
|
120
|
+
}
|
|
121
|
+
const core: Record<string, unknown> = {}
|
|
122
|
+
for (const [k, v] of Object.entries(cdro as Record<string, unknown>)) {
|
|
123
|
+
if (CDRO_ENVELOPE_FIELD_SET.has(k)) continue
|
|
124
|
+
core[k] = v
|
|
125
|
+
}
|
|
126
|
+
return core
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compute the OID of a full CDRO over its content core (see
|
|
131
|
+
* `cdroContentCore`). This is the correct way to derive identity for a
|
|
132
|
+
* complete CDRO: it hashes `authority`, `supersedes`, `gap_version`, `body`,
|
|
133
|
+
* and every other content field, so the L4 authority block is identity-bound
|
|
134
|
+
* and cannot be silently dropped.
|
|
135
|
+
*
|
|
136
|
+
* INVARIANT (ADR_019): `cdroOid(obj)` yields the SAME OID whether `obj` is
|
|
137
|
+
* pre- or post-attestation, because the detached envelope fields are stripped
|
|
138
|
+
* (see `CDRO_ENVELOPE_FIELDS`). This is what lets a third party recompute the
|
|
139
|
+
* OID of a signed receipt and match the value the signer stamped.
|
|
140
|
+
*
|
|
141
|
+
* Note this differs from `oidOf(cdro.body)`: a CDRO's identity is over the
|
|
142
|
+
* whole content core, not just its body.
|
|
143
|
+
*/
|
|
144
|
+
export function cdroOid(cdro: unknown): string {
|
|
145
|
+
return oidOf(cdroContentCore(cdro))
|
|
146
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — sensitivity.ts
|
|
3
|
+
*
|
|
4
|
+
* L4 GOVERNANCE — the propagating sensitivity label (OBJECT_MODEL_CLEANSHEET
|
|
5
|
+
* §3.4, SRAID_FOUNDATION_PUNCHLIST A5). A single coarse, OPAQUE tier on the
|
|
6
|
+
* CDRO that retrieval, summarization, and consolidation MUST carry forward
|
|
7
|
+
* monotonically: a derived object inherits the HIGHEST tier among its sources,
|
|
8
|
+
* so a summary of high-sensitivity inputs cannot be downgraded.
|
|
9
|
+
*
|
|
10
|
+
* ── Why the tiers are OPAQUE ORDINALS, not literal categories ───────────────
|
|
11
|
+
*
|
|
12
|
+
* SPEC §7 is normative and explicit: the envelope is a NON-encrypted, signed,
|
|
13
|
+
* publicly visible surface. A literal classification label (e.g. `"phi"`,
|
|
14
|
+
* `"health"`, `"financial"`) on that surface would LEAK the nature of the
|
|
15
|
+
* encrypted `body` — an observer who cannot read the ciphertext could still
|
|
16
|
+
* read "this is health data" off the envelope. SPEC §7 therefore requires a
|
|
17
|
+
* "coarse, OPAQUE sensitivity classifier", and the §9 reserved note says the
|
|
18
|
+
* same.
|
|
19
|
+
*
|
|
20
|
+
* So the tier set here is a small, COARSE, ORDERED ladder of opaque levels
|
|
21
|
+
* (`s0` < `s1` < `s2` < `s3` < `s4`). It carries an ORDERING (needed for the
|
|
22
|
+
* monotone `max()` lattice — standard lattice-based information-flow labeling,
|
|
23
|
+
* Denning 1976) but NO semantic category. The mapping from a regulatory
|
|
24
|
+
* category (PHI, PII, secret, …) to a tier is a higher-layer POLICY concern
|
|
25
|
+
* kept OFF the public envelope: an operator's policy decides "PHI → s3"
|
|
26
|
+
* privately; the wire only ever shows `s3`. This is the "coarse, opaque tier
|
|
27
|
+
* (or opaque policy-scope handle)" required by the task and SPEC §7.
|
|
28
|
+
*
|
|
29
|
+
* The field is hashed into the OID (it is a content-core field; see
|
|
30
|
+
* `cdroContentCore` in oid.ts), so it cannot be silently stripped or
|
|
31
|
+
* downgraded without changing the OID and breaking the signature.
|
|
32
|
+
*
|
|
33
|
+
* NOTE on maturity (CLAIMS_DISCIPLINE): this module ships the FIELD, the
|
|
34
|
+
* lattice, the monotone `max()` carry-forward, and the validator, with
|
|
35
|
+
* vectors. The commitment-based selective-disclosure scheme that would
|
|
36
|
+
* CONCEAL the tier itself (SPEC §7 / §9) is reserved and NOT built here.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The coarse, opaque sensitivity tiers, lowest to highest. These are ORDINAL
|
|
41
|
+
* and OPAQUE by design — they convey relative sensitivity for the monotone
|
|
42
|
+
* propagation lattice WITHOUT naming a content category (per SPEC §7, a
|
|
43
|
+
* literal category on the public envelope would leak what the encrypted body
|
|
44
|
+
* is). The mapping of a real-world classification to a tier is private policy,
|
|
45
|
+
* not part of this taxonomy.
|
|
46
|
+
*
|
|
47
|
+
* s0 — lowest / unclassified (the default floor; freely shareable)
|
|
48
|
+
* s1 — low
|
|
49
|
+
* s2 — moderate
|
|
50
|
+
* s3 — high
|
|
51
|
+
* s4 — highest / most restricted
|
|
52
|
+
*
|
|
53
|
+
* Five coarse levels is deliberate: enough granularity for a useful lattice,
|
|
54
|
+
* few enough to stay coarse (so the tier alone is weakly distinguishing).
|
|
55
|
+
*/
|
|
56
|
+
export type SensitivityTier = 's0' | 's1' | 's2' | 's3' | 's4'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The ordered tier ladder, lowest-first. The index IS the rank used by the
|
|
60
|
+
* `max()` lattice. Frozen so the ordering is immutable at runtime.
|
|
61
|
+
*/
|
|
62
|
+
export const SENSITIVITY_TIERS: readonly SensitivityTier[] = Object.freeze([
|
|
63
|
+
's0',
|
|
64
|
+
's1',
|
|
65
|
+
's2',
|
|
66
|
+
's3',
|
|
67
|
+
's4',
|
|
68
|
+
] as const)
|
|
69
|
+
|
|
70
|
+
/** The default tier for an object that declares none — the lattice floor. */
|
|
71
|
+
export const SENSITIVITY_DEFAULT: SensitivityTier = 's0'
|
|
72
|
+
|
|
73
|
+
const RANK: ReadonlyMap<string, number> = new Map(
|
|
74
|
+
SENSITIVITY_TIERS.map((t, i) => [t, i]),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/** True iff `x` is a defined `SensitivityTier`. */
|
|
78
|
+
export function isSensitivityTier(x: unknown): x is SensitivityTier {
|
|
79
|
+
return typeof x === 'string' && RANK.has(x)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The ordinal rank of a tier (0 = lowest). Throws on an unknown tier so a
|
|
84
|
+
* typo can never silently rank as the floor (which would be a downgrade
|
|
85
|
+
* footgun in the lattice).
|
|
86
|
+
*/
|
|
87
|
+
export function sensitivityRank(tier: SensitivityTier): number {
|
|
88
|
+
const r = RANK.get(tier)
|
|
89
|
+
if (r === undefined) {
|
|
90
|
+
throw new RangeError(`sensitivityRank: "${String(tier)}" is not a known tier`)
|
|
91
|
+
}
|
|
92
|
+
return r
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The monotone lattice join: the HIGHER (more restricted) of two tiers.
|
|
97
|
+
* `max('s1','s3') === 's3'`. This is the per-pair operation behind
|
|
98
|
+
* carry-forward.
|
|
99
|
+
*/
|
|
100
|
+
export function sensitivityMax(
|
|
101
|
+
a: SensitivityTier,
|
|
102
|
+
b: SensitivityTier,
|
|
103
|
+
): SensitivityTier {
|
|
104
|
+
return sensitivityRank(a) >= sensitivityRank(b) ? a : b
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Carry-forward over a set of source tiers: the highest tier present.
|
|
109
|
+
*
|
|
110
|
+
* This is the rule that makes sensitivity MONOTONE under derivation
|
|
111
|
+
* (retrieval / summarization / consolidation): a derived object's tier =
|
|
112
|
+
* max(tiers of all its `consolidated_from` / `derived_from` sources). It can
|
|
113
|
+
* only ever go UP, never down — a summary of high-sensitivity inputs stays at
|
|
114
|
+
* the highest input tier.
|
|
115
|
+
*
|
|
116
|
+
* `undefined`/absent source tiers are treated as the floor (`s0`): a source
|
|
117
|
+
* that declared no tier cannot pull the result down, and an explicit higher
|
|
118
|
+
* source always wins. An empty input set returns the floor.
|
|
119
|
+
*/
|
|
120
|
+
export function sensitivityCarryForward(
|
|
121
|
+
sources: ReadonlyArray<SensitivityTier | undefined | null>,
|
|
122
|
+
): SensitivityTier {
|
|
123
|
+
let acc: SensitivityTier = SENSITIVITY_DEFAULT
|
|
124
|
+
for (const s of sources) {
|
|
125
|
+
if (s === undefined || s === null) continue
|
|
126
|
+
if (!isSensitivityTier(s)) {
|
|
127
|
+
throw new RangeError(
|
|
128
|
+
`sensitivityCarryForward: "${String(s)}" is not a known tier`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
acc = sensitivityMax(acc, s)
|
|
132
|
+
}
|
|
133
|
+
return acc
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Guard a proposed sensitivity assignment against monotone violation: the
|
|
138
|
+
* `proposed` tier of a derived object MUST be at least as high as the
|
|
139
|
+
* carry-forward of its `sources`. Returns the floor the derived object is NOT
|
|
140
|
+
* allowed to fall below, plus whether `proposed` honors it.
|
|
141
|
+
*
|
|
142
|
+
* Use this to REJECT an attempt to label a summary of `s3` inputs as `s1`.
|
|
143
|
+
*/
|
|
144
|
+
export function sensitivityMonotoneCheck(
|
|
145
|
+
proposed: SensitivityTier,
|
|
146
|
+
sources: ReadonlyArray<SensitivityTier | undefined | null>,
|
|
147
|
+
): { ok: boolean; floor: SensitivityTier; proposed: SensitivityTier } {
|
|
148
|
+
const floor = sensitivityCarryForward(sources)
|
|
149
|
+
return {
|
|
150
|
+
ok: sensitivityRank(proposed) >= sensitivityRank(floor),
|
|
151
|
+
floor,
|
|
152
|
+
proposed,
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — signature.ts
|
|
3
|
+
*
|
|
4
|
+
* Hybrid ed25519 + ml-dsa-65 signature verification for CDRO envelopes.
|
|
5
|
+
*
|
|
6
|
+
* SynOI signs every Decision Receipt and every governance object with
|
|
7
|
+
* BOTH a classical Ed25519 signature and a post-quantum ML-DSA-65
|
|
8
|
+
* signature. The verifier in this package requires BOTH to be valid
|
|
9
|
+
* before returning `valid: true` — even though either alone is
|
|
10
|
+
* cryptographically meaningful, signed objects exit the system only when
|
|
11
|
+
* both check out.
|
|
12
|
+
*
|
|
13
|
+
* The signature bytes are computed over the canonical form of the object
|
|
14
|
+
* minus the `signature` field itself. Callers are expected to produce
|
|
15
|
+
* that canonical form (typically via `canonicalize()` in this package)
|
|
16
|
+
* and pass it as `canonical` here — verifiers MUST use the same exact
|
|
17
|
+
* bytes that the signer used, so callers control that contract.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { verifyEd25519 } from './ed25519.js'
|
|
21
|
+
import { decodeBase64Strict } from './internal/base64.js'
|
|
22
|
+
import { verifyMlDsa65 } from './mldsa.js'
|
|
23
|
+
import type { SignatureEnvelope } from './types.js'
|
|
24
|
+
|
|
25
|
+
export interface VerifySignatureInput {
|
|
26
|
+
/** The canonical bytes that were signed (string is utf-8 encoded). */
|
|
27
|
+
canonical: string | Uint8Array
|
|
28
|
+
/** Signature envelope to verify. */
|
|
29
|
+
envelope: SignatureEnvelope
|
|
30
|
+
/** Raw 32-byte Ed25519 public key. */
|
|
31
|
+
ed25519_pub: Uint8Array
|
|
32
|
+
/** Raw ML-DSA-65 public key bytes. */
|
|
33
|
+
ml_dsa_pub: Uint8Array
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface VerifySignatureResult {
|
|
37
|
+
/** True only when BOTH signatures verified successfully. */
|
|
38
|
+
valid: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Human-readable reasons for failure. Empty when valid. Possible
|
|
41
|
+
* values: 'ed25519-invalid', 'ml-dsa-invalid', 'ed25519-malformed',
|
|
42
|
+
* 'ml-dsa-malformed', 'envelope-malformed'.
|
|
43
|
+
*/
|
|
44
|
+
reasons: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Verify a hybrid CDRO signature envelope. Returns `valid: true` only
|
|
49
|
+
* when both the Ed25519 and the ML-DSA-65 signatures verify against the
|
|
50
|
+
* supplied public keys and canonical bytes.
|
|
51
|
+
*/
|
|
52
|
+
export function verifySignature(input: VerifySignatureInput): VerifySignatureResult {
|
|
53
|
+
const reasons: string[] = []
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
!input.envelope ||
|
|
57
|
+
typeof input.envelope.ed25519 !== 'string' ||
|
|
58
|
+
typeof input.envelope.ml_dsa_65 !== 'string' ||
|
|
59
|
+
typeof input.envelope.signer_kid !== 'string'
|
|
60
|
+
) {
|
|
61
|
+
return { valid: false, reasons: ['envelope-malformed'] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const message =
|
|
65
|
+
typeof input.canonical === 'string'
|
|
66
|
+
? new TextEncoder().encode(input.canonical)
|
|
67
|
+
: input.canonical
|
|
68
|
+
|
|
69
|
+
// Decode the two signatures from base64. If either fails to decode,
|
|
70
|
+
// the envelope is malformed — but we still want to verify the OTHER
|
|
71
|
+
// signature so we can report both reasons in one pass.
|
|
72
|
+
let edSig: Uint8Array | null = null
|
|
73
|
+
let mlSig: Uint8Array | null = null
|
|
74
|
+
try {
|
|
75
|
+
edSig = fromBase64(input.envelope.ed25519)
|
|
76
|
+
} catch {
|
|
77
|
+
reasons.push('ed25519-malformed')
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
mlSig = fromBase64(input.envelope.ml_dsa_65)
|
|
81
|
+
} catch {
|
|
82
|
+
reasons.push('ml-dsa-malformed')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let edOk = false
|
|
86
|
+
let mlOk = false
|
|
87
|
+
|
|
88
|
+
if (edSig) {
|
|
89
|
+
try {
|
|
90
|
+
edOk = verifyEd25519(edSig, message, input.ed25519_pub)
|
|
91
|
+
} catch {
|
|
92
|
+
// Treat invalid signature shape as a verification failure rather
|
|
93
|
+
// than a thrown exception, so callers can rely on a clean result.
|
|
94
|
+
edOk = false
|
|
95
|
+
}
|
|
96
|
+
if (!edOk) reasons.push('ed25519-invalid')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (mlSig) {
|
|
100
|
+
try {
|
|
101
|
+
mlOk = verifyMlDsa65(mlSig, message, input.ml_dsa_pub)
|
|
102
|
+
} catch {
|
|
103
|
+
mlOk = false
|
|
104
|
+
}
|
|
105
|
+
if (!mlOk) reasons.push('ml-dsa-invalid')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { valid: edOk && mlOk, reasons }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Base64 helpers (standard, with `=` padding) ──────────────────────────────
|
|
112
|
+
|
|
113
|
+
// Strict standard base64. Throws Error('base64-malformed') on any deviation
|
|
114
|
+
// rather than silently truncating at the first illegal byte. Both call sites
|
|
115
|
+
// wrap this in try/catch and map the throw to a *-malformed reason, so
|
|
116
|
+
// verifySignature never throws.
|
|
117
|
+
function fromBase64(s: string): Uint8Array {
|
|
118
|
+
return decodeBase64Strict(s)
|
|
119
|
+
}
|