@synoi/sraid 0.2.0 → 0.4.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/README.md +156 -50
- package/SPEC.md +2 -2
- package/dist/attestation.d.ts +8 -49
- package/dist/attestation.d.ts.map +1 -1
- package/dist/attestation.js +15 -46
- package/dist/attestation.js.map +1 -1
- package/dist/capability.d.ts +59 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +44 -0
- package/dist/capability.js.map +1 -0
- package/dist/index.d.ts +13 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/index.js.map +1 -1
- package/dist/internal/attestation-core.d.ts +85 -0
- package/dist/internal/attestation-core.d.ts.map +1 -0
- package/dist/internal/attestation-core.js +78 -0
- package/dist/internal/attestation-core.js.map +1 -0
- package/dist/internal/base64-browser.d.ts +31 -0
- package/dist/internal/base64-browser.d.ts.map +1 -0
- package/dist/internal/base64-browser.js +39 -0
- package/dist/internal/base64-browser.js.map +1 -0
- package/dist/internal/base64-validate.d.ts +19 -0
- package/dist/internal/base64-validate.d.ts.map +1 -0
- package/dist/internal/base64-validate.js +33 -0
- package/dist/internal/base64-validate.js.map +1 -0
- package/dist/internal/base64.d.ts +7 -5
- package/dist/internal/base64.d.ts.map +1 -1
- package/dist/internal/base64.js +7 -19
- package/dist/internal/base64.js.map +1 -1
- package/dist/internal/content-core.d.ts +68 -0
- package/dist/internal/content-core.d.ts.map +1 -0
- package/dist/internal/content-core.js +87 -0
- package/dist/internal/content-core.js.map +1 -0
- package/dist/internal/ed25519-browser.d.ts +33 -0
- package/dist/internal/ed25519-browser.d.ts.map +1 -0
- package/dist/internal/ed25519-browser.js +79 -0
- package/dist/internal/ed25519-browser.js.map +1 -0
- package/dist/internal/mldsa-browser.d.ts +27 -0
- package/dist/internal/mldsa-browser.d.ts.map +1 -0
- package/dist/internal/mldsa-browser.js +35 -0
- package/dist/internal/mldsa-browser.js.map +1 -0
- package/dist/internal/sha256-browser.d.ts +17 -0
- package/dist/internal/sha256-browser.d.ts.map +1 -0
- package/dist/internal/sha256-browser.js +27 -0
- package/dist/internal/sha256-browser.js.map +1 -0
- package/dist/lineage.js +0 -0
- package/dist/lineage.js.map +1 -1
- package/dist/oid.d.ts +2 -52
- package/dist/oid.d.ts.map +1 -1
- package/dist/oid.js +9 -71
- package/dist/oid.js.map +1 -1
- package/dist/types.d.ts +4 -30
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +2 -17
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +0 -57
- package/dist/validate.js.map +1 -1
- package/dist/verify-browser.d.ts +80 -0
- package/dist/verify-browser.d.ts.map +1 -0
- package/dist/verify-browser.js +157 -0
- package/dist/verify-browser.js.map +1 -0
- package/package.json +22 -3
- package/src/attestation.ts +29 -88
- package/src/capability.ts +69 -0
- package/src/index.ts +27 -36
- package/src/internal/attestation-core.ts +122 -0
- package/src/internal/base64-browser.ts +39 -0
- package/src/internal/base64-validate.ts +31 -0
- package/src/internal/base64.ts +7 -16
- package/src/internal/content-core.ts +88 -0
- package/src/internal/ed25519-browser.ts +85 -0
- package/src/internal/mldsa-browser.ts +39 -0
- package/src/internal/sha256-browser.ts +28 -0
- package/src/lineage.ts +0 -0
- package/src/oid.ts +10 -73
- package/src/types.ts +23 -31
- package/src/validate.ts +1 -64
- package/src/verify-browser.ts +185 -0
- package/dist/authority.d.ts +0 -351
- package/dist/authority.d.ts.map +0 -1
- package/dist/authority.js +0 -563
- package/dist/authority.js.map +0 -1
- package/src/authority.ts +0 -849
package/src/index.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @synoi/sraid — public surface.
|
|
3
3
|
*
|
|
4
|
-
* SRAID
|
|
4
|
+
* SRAID is the object-identity layer: it defines what a signed object IS and
|
|
5
|
+
* how anyone re-derives and verifies its identity offline.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* L4 Apps
|
|
7
|
+
* Scope, precisely:
|
|
8
|
+
* IN canonical serializer, OID computation, hybrid Ed25519 + ML-DSA-65
|
|
9
|
+
* attestation verification, lineage resolution, shape validators.
|
|
10
|
+
* OUT storage, transport, key resolution, revocation, policy, enforcement.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* It verifies SIGNATURES, not IDENTITIES: `signer_kid` is an opaque string
|
|
13
|
+
* this package never resolves, and nothing here checks revocation. Deciding
|
|
14
|
+
* whether a key was trusted at signing time is a caller/directory concern.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
// Types
|
|
@@ -23,8 +23,6 @@ export type {
|
|
|
23
23
|
CDRO,
|
|
24
24
|
LineageLink,
|
|
25
25
|
LinkRel,
|
|
26
|
-
SRO,
|
|
27
|
-
SROBody,
|
|
28
26
|
SensitivityTier,
|
|
29
27
|
SignatureEnvelope,
|
|
30
28
|
} from './types.js'
|
|
@@ -53,7 +51,7 @@ export { verifyMlDsa65, isNativeMlDsaAvailable } from './mldsa.js'
|
|
|
53
51
|
// rotated or compromised key cannot be served stale on a later verify.
|
|
54
52
|
export { evictKeyFromCaches } from './internal/key-cache.js'
|
|
55
53
|
|
|
56
|
-
//
|
|
54
|
+
// DSSE attestation — PAE type-binding, hybrid Ed25519 + ML-DSA-65 both
|
|
57
55
|
// required. The preferred signing path (replaces the legacy bare-bytes
|
|
58
56
|
// SignatureEnvelope; closes the cross-type confusion gap SRAID F7 / A4).
|
|
59
57
|
export {
|
|
@@ -65,7 +63,8 @@ export {
|
|
|
65
63
|
type VerifyAttestationResult,
|
|
66
64
|
} from './attestation.js'
|
|
67
65
|
|
|
68
|
-
//
|
|
66
|
+
// Lineage (Merkle-DAG) — unify the `supersedes` string with the identity-bound
|
|
67
|
+
// `prev`/`links` edges; latest-wins resolution over a version set.
|
|
69
68
|
export {
|
|
70
69
|
lineageLinks,
|
|
71
70
|
supersededOids,
|
|
@@ -73,7 +72,7 @@ export {
|
|
|
73
72
|
type LatestWinsResult,
|
|
74
73
|
} from './lineage.js'
|
|
75
74
|
|
|
76
|
-
//
|
|
75
|
+
// Propagating sensitivity — coarse, opaque tier + monotone max() carry-forward.
|
|
77
76
|
export {
|
|
78
77
|
SENSITIVITY_TIERS,
|
|
79
78
|
SENSITIVITY_DEFAULT,
|
|
@@ -84,35 +83,27 @@ export {
|
|
|
84
83
|
sensitivityMonotoneCheck,
|
|
85
84
|
} from './sensitivity.js'
|
|
86
85
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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'
|
|
86
|
+
// Capability matching + the live-grant-status contract. The leaf primitive a
|
|
87
|
+
// grant store needs: pure string logic, no crypto, no I/O, no live claim.
|
|
88
|
+
export { capabilityCovers } from './capability.js'
|
|
89
|
+
export type { AuthorityResolver, GrantStatus } from './capability.js'
|
|
108
90
|
|
|
109
91
|
// Shape validators.
|
|
110
92
|
export {
|
|
111
93
|
validateCdro,
|
|
112
|
-
validateSro,
|
|
113
94
|
validateSignatureEnvelope,
|
|
114
95
|
validateAttestationEnvelope,
|
|
115
96
|
validateAuthorityBlock,
|
|
116
97
|
validateLineageLink,
|
|
117
98
|
type ValidationResult,
|
|
118
99
|
} from './validate.js'
|
|
100
|
+
|
|
101
|
+
// MOVED IN 0.4.0 — the grant and delegation-chain VERIFIERS (`verifyAuthority`,
|
|
102
|
+
// `verifyDelegationChain`, `MAX_DELEGATION_DEPTH`) are authorization policy,
|
|
103
|
+
// not object identity, and are no longer exported from this entry. They live
|
|
104
|
+
// at the `@synoi/sraid/authority` subpath, unchanged and still verify-only.
|
|
105
|
+
//
|
|
106
|
+
// `capabilityCovers`, `AuthorityResolver` and `GrantStatus` did NOT move: they
|
|
107
|
+
// are the leaf primitive and the contract, and are exported above.
|
|
108
|
+
//
|
|
109
|
+
// REMOVED IN 0.4.0 — `validateSro`, `SRO`, `SROBody`. See CHANGELOG 0.4.0.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/attestation-core.ts
|
|
3
|
+
*
|
|
4
|
+
* The crypto-agnostic core of the L2 DSSE attestation profile: the PAE
|
|
5
|
+
* encoding, the algorithm identifiers, the public verify input/result types,
|
|
6
|
+
* the both-required signature lookup, and the envelope-shape predicate.
|
|
7
|
+
*
|
|
8
|
+
* This is a PURE module — no crypto, no node:crypto, no Buffer — so the same
|
|
9
|
+
* PAE bytes, AND-policy shape, and type contract are shared byte-for-byte by
|
|
10
|
+
* BOTH the node default entry (attestation.ts, node:crypto verifiers) and the
|
|
11
|
+
* browser verify surface (verify-browser.ts, WebCrypto + @noble verifiers).
|
|
12
|
+
* Divergent PAE or shape logic between the two would be a signature-validity
|
|
13
|
+
* hazard; keeping it in one place removes that risk.
|
|
14
|
+
*
|
|
15
|
+
* The verify ORCHESTRATION (decode each sig, run both verifiers, gate on the
|
|
16
|
+
* AND policy) is intentionally NOT here: the node path is synchronous
|
|
17
|
+
* (node:crypto verify) and the browser path is asynchronous (WebCrypto verify),
|
|
18
|
+
* so a single shared body cannot serve both. Each entry keeps its own thin
|
|
19
|
+
* orchestration and reuses these leaf helpers.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { AttestationEnvelope, AttestationSignature } from '../types.js'
|
|
23
|
+
|
|
24
|
+
/** The single supported algorithm identifiers for the JSON profile. */
|
|
25
|
+
export const ALG_ED25519 = 'ed25519'
|
|
26
|
+
export const ALG_ML_DSA_65 = 'ml-dsa-65'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The DSSE Pre-Authentication Encoding (PAE).
|
|
30
|
+
*
|
|
31
|
+
* Per the DSSE spec:
|
|
32
|
+
*
|
|
33
|
+
* PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
|
|
34
|
+
*
|
|
35
|
+
* where:
|
|
36
|
+
* - SP is a single ASCII space (0x20),
|
|
37
|
+
* - LEN(x) is the ASCII-decimal byte length of x's UTF-8 encoding,
|
|
38
|
+
* - type and body are the raw UTF-8 bytes (NOT base64).
|
|
39
|
+
*
|
|
40
|
+
* Binding the payloadType (and both lengths) into the signed bytes is what
|
|
41
|
+
* makes a signature non-transferable across object types. Returns the raw
|
|
42
|
+
* bytes to be signed/verified.
|
|
43
|
+
*/
|
|
44
|
+
export function pae(payloadType: string, payload: string | Uint8Array): Uint8Array {
|
|
45
|
+
const enc = new TextEncoder()
|
|
46
|
+
const typeBytes = enc.encode(payloadType)
|
|
47
|
+
const bodyBytes = typeof payload === 'string' ? enc.encode(payload) : payload
|
|
48
|
+
|
|
49
|
+
const prefix = enc.encode(
|
|
50
|
+
`DSSEv1 ${typeBytes.length} ${payloadType} ${bodyBytes.length} `,
|
|
51
|
+
)
|
|
52
|
+
const out = new Uint8Array(prefix.length + bodyBytes.length)
|
|
53
|
+
out.set(prefix, 0)
|
|
54
|
+
out.set(bodyBytes, prefix.length)
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface VerifyAttestationInput {
|
|
59
|
+
/**
|
|
60
|
+
* The DSSE attestation envelope to verify. Its `payloadType` is bound into
|
|
61
|
+
* the PAE; its `payload` is the canonical UTF-8 string that was signed.
|
|
62
|
+
*/
|
|
63
|
+
envelope: AttestationEnvelope
|
|
64
|
+
/** Raw 32-byte Ed25519 public key. */
|
|
65
|
+
ed25519_pub: Uint8Array
|
|
66
|
+
/** Raw ML-DSA-65 public key bytes. */
|
|
67
|
+
ml_dsa_pub: Uint8Array
|
|
68
|
+
/**
|
|
69
|
+
* Optional. If supplied, the verifier asserts the envelope's `payloadType`
|
|
70
|
+
* equals this value before verifying signatures — an explicit type-pinning
|
|
71
|
+
* check on top of the structural PAE binding. A mismatch fails with
|
|
72
|
+
* `payload-type-mismatch`.
|
|
73
|
+
*/
|
|
74
|
+
expectedPayloadType?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VerifyAttestationResult {
|
|
78
|
+
/** True only when BOTH required signatures verified over the same PAE. */
|
|
79
|
+
valid: boolean
|
|
80
|
+
/**
|
|
81
|
+
* Human-readable failure reasons. Empty when valid. Possible values:
|
|
82
|
+
* 'envelope-malformed', 'payload-type-mismatch', 'missing-ed25519',
|
|
83
|
+
* 'missing-ml-dsa-65', 'ed25519-malformed', 'ml-dsa-malformed',
|
|
84
|
+
* 'ed25519-invalid', 'ml-dsa-invalid'.
|
|
85
|
+
*/
|
|
86
|
+
reasons: string[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Structural well-formedness of an attestation envelope: a non-null object with
|
|
91
|
+
* a non-empty string `payloadType`, a string `payload`, and an array
|
|
92
|
+
* `signatures`. A false result maps to the `envelope-malformed` reason. This is
|
|
93
|
+
* the SINGLE definition of envelope shape, shared by the node and browser
|
|
94
|
+
* verifiers so they reject the same inputs.
|
|
95
|
+
*/
|
|
96
|
+
export function isWellFormedEnvelope(env: unknown): env is AttestationEnvelope {
|
|
97
|
+
return (
|
|
98
|
+
env !== null &&
|
|
99
|
+
typeof env === 'object' &&
|
|
100
|
+
typeof (env as AttestationEnvelope).payloadType === 'string' &&
|
|
101
|
+
(env as AttestationEnvelope).payloadType.length > 0 &&
|
|
102
|
+
typeof (env as AttestationEnvelope).payload === 'string' &&
|
|
103
|
+
Array.isArray((env as AttestationEnvelope).signatures)
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find the first well-formed signature entry for `alg` (a `{ alg, sig }` object
|
|
109
|
+
* whose `sig` is a string). Returns undefined when absent; the AND policy in
|
|
110
|
+
* each verifier turns a missing pair member into a `missing-*` reason.
|
|
111
|
+
*/
|
|
112
|
+
export function findSig(
|
|
113
|
+
sigs: readonly AttestationSignature[],
|
|
114
|
+
alg: string,
|
|
115
|
+
): AttestationSignature | undefined {
|
|
116
|
+
for (const s of sigs) {
|
|
117
|
+
if (s && typeof s === 'object' && s.alg === alg && typeof s.sig === 'string') {
|
|
118
|
+
return s
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/base64-browser.ts
|
|
3
|
+
*
|
|
4
|
+
* Strict standard-base64 (RFC 4648 §4, with `=` padding) decode for the
|
|
5
|
+
* BROWSER verify surface.
|
|
6
|
+
*
|
|
7
|
+
* INTERNAL: not exported from any public entry.
|
|
8
|
+
*
|
|
9
|
+
* Why a separate decoder: the node decoder (./base64.ts `decodeBase64Strict`)
|
|
10
|
+
* finishes with `Buffer.from(s, 'base64')`. `Buffer` is a NODE global that does
|
|
11
|
+
* not exist in a browser / service-worker / Chrome-extension context — and,
|
|
12
|
+
* crucially, it is NOT a `node:` specifier, so an esbuild `node:`-reference scan
|
|
13
|
+
* would NOT catch it. A `Buffer`-based decode would bundle clean yet throw
|
|
14
|
+
* `ReferenceError: Buffer is not defined` at runtime. This decoder is
|
|
15
|
+
* Buffer-free: it reuses the pure `assertBase64` validation (regex/length/
|
|
16
|
+
* padding only, from ./base64-validate.js — NOT ./base64.js, whose graph pulls
|
|
17
|
+
* in the `Buffer` decode) and decodes via the standard `atob` global, which IS
|
|
18
|
+
* available in browsers, dedicated/shared workers, and MV3 extension service
|
|
19
|
+
* workers.
|
|
20
|
+
*
|
|
21
|
+
* Same strict contract as the node path: throws Error('base64-malformed') on
|
|
22
|
+
* any deviation (illegal char, bad length, bad padding) rather than silently
|
|
23
|
+
* truncating, so a malformed signature is rejected loudly, never decoded to a
|
|
24
|
+
* short prefix that then "verifies" against something unintended.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { assertBase64 } from './base64-validate.js'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strictly decode standard base64 to raw bytes using only browser-safe globals.
|
|
31
|
+
* Throws Error('base64-malformed') on any non-conforming input.
|
|
32
|
+
*/
|
|
33
|
+
export function decodeBase64StrictBrowser(s: string): Uint8Array {
|
|
34
|
+
assertBase64(s)
|
|
35
|
+
const bin = atob(s)
|
|
36
|
+
const out = new Uint8Array(bin.length)
|
|
37
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/base64-validate.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure strict standard-base64 (RFC 4648 §4, with `=` padding) VALIDATION.
|
|
5
|
+
*
|
|
6
|
+
* INTERNAL: not exported from any public entry.
|
|
7
|
+
*
|
|
8
|
+
* This is the Buffer-free, node:crypto-free half of base64 handling, split out
|
|
9
|
+
* so the browser decoder (./base64-browser.ts) can validate without importing
|
|
10
|
+
* ./base64.ts (whose `decodeBase64Strict` uses the node `Buffer` global — safe
|
|
11
|
+
* in node, a runtime `ReferenceError` in a browser). The node decoder re-uses
|
|
12
|
+
* this same validator, so both paths reject exactly the same inputs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Standard base64 alphabet; `=` only as 1-2 trailing pad chars. Total length
|
|
16
|
+
// must be a multiple of 4.
|
|
17
|
+
const STD_B64 = /^[A-Za-z0-9+/]*={0,2}$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate strict standard base64. Throws Error('base64-malformed') on any
|
|
21
|
+
* non-conforming input.
|
|
22
|
+
*/
|
|
23
|
+
export function assertBase64(s: string): void {
|
|
24
|
+
if (typeof s !== 'string') throw new Error('base64-malformed')
|
|
25
|
+
if (s.length % 4 !== 0) throw new Error('base64-malformed')
|
|
26
|
+
if (!STD_B64.test(s)) throw new Error('base64-malformed')
|
|
27
|
+
// `=` may appear only in the final 1-2 positions. The regex `={0,2}` anchored
|
|
28
|
+
// at end already guarantees pad chars are contiguous and trailing, but a pad
|
|
29
|
+
// char earlier in the body would have been rejected by the alphabet class —
|
|
30
|
+
// so an in-body `=` cannot pass STD_B64. No extra check needed.
|
|
31
|
+
}
|
package/src/internal/base64.ts
CHANGED
|
@@ -14,25 +14,16 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Canonical inputs carry no surrounding whitespace, so no trim is applied;
|
|
16
16
|
* leading/trailing whitespace is itself a rejection.
|
|
17
|
+
*
|
|
18
|
+
* The strict VALIDATION (alphabet/length/padding) lives in the pure, Buffer-
|
|
19
|
+
* free ./base64-validate.ts so the browser decoder can share it; this module
|
|
20
|
+
* adds the node `Buffer`-based decode on top and re-exports `assertBase64` so
|
|
21
|
+
* existing importers of `./base64.js` are unchanged.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
// must be a multiple of 4.
|
|
21
|
-
const STD_B64 = /^[A-Za-z0-9+/]*={0,2}$/
|
|
24
|
+
import { assertBase64 } from './base64-validate.js'
|
|
22
25
|
|
|
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
|
-
}
|
|
26
|
+
export { assertBase64 }
|
|
36
27
|
|
|
37
28
|
/**
|
|
38
29
|
* Strictly decode standard base64 to raw bytes. Throws
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/content-core.ts
|
|
3
|
+
*
|
|
4
|
+
* The CDRO OID content-core projection: the normative strip-set and the
|
|
5
|
+
* field-removal function that together define WHAT bytes an OID is computed
|
|
6
|
+
* over. This is a PURE module — no hashing, no node:crypto, no Buffer — so it
|
|
7
|
+
* is safe to import from both the node default entry (via oid.ts) and the
|
|
8
|
+
* browser verify surface (via verify-browser.ts) without dragging node
|
|
9
|
+
* builtins into a browser bundle.
|
|
10
|
+
*
|
|
11
|
+
* This is the SINGLE NORMATIVE SOURCE of the CDRO OID projection (ADR_019).
|
|
12
|
+
* oid.ts re-exports these symbols and layers the SHA-256 hashing on top; every
|
|
13
|
+
* other surface (GAP SDKs, the gateway signer, IMPLEMENTING.md) derives from
|
|
14
|
+
* THIS set, never re-lists it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The detached-signature / envelope fields removed by `cdroContentCore`
|
|
19
|
+
* before hashing. This is the SINGLE NORMATIVE strip-set for the CDRO OID
|
|
20
|
+
* projection (ADR_019 decision 1); every other surface (GAP SDKs, the
|
|
21
|
+
* gateway signer, IMPLEMENTING.md) derives from THIS set, never re-lists it.
|
|
22
|
+
*
|
|
23
|
+
* The set is defined SEMANTICALLY: it is "every field produced BY the signer
|
|
24
|
+
* after canonicalization, plus the OID output itself." Concretely:
|
|
25
|
+
*
|
|
26
|
+
* oid — the projection OUTPUT (cannot be an input to itself).
|
|
27
|
+
* signature — legacy hybrid SignatureEnvelope (attaches after hash).
|
|
28
|
+
* ml_dsa_signature — detached PQ signature (attaches after hash).
|
|
29
|
+
* signature_key_id — signer-stamped key id (produced by the signer).
|
|
30
|
+
* signature_algorithm — signer-stamped alg id (produced by the signer).
|
|
31
|
+
* attestation — DSSE AttestationEnvelope (attaches after hash).
|
|
32
|
+
*
|
|
33
|
+
* EVERYTHING ELSE IS KEPT and hashed into the OID, including in particular:
|
|
34
|
+
* - `gap_version` — IN identity so a protocol downgrade is OID-detectable.
|
|
35
|
+
* - `supersedes` — IN identity because the SRAID Merkle-DAG head-proves-
|
|
36
|
+
* history property requires every lineage edge inside the
|
|
37
|
+
* hash. Superseding mints a NEW object; it never mutates
|
|
38
|
+
* the old one's bytes, so keeping it here is safe and
|
|
39
|
+
* makes the lineage edge tamper-evident.
|
|
40
|
+
* - `type`, `sraid_version`, `tenant_id`, `created_at_ms`, `created_by`,
|
|
41
|
+
* `body`, `authority`, `sensitivity`, `prev`, `links`, and any other
|
|
42
|
+
* content field.
|
|
43
|
+
*
|
|
44
|
+
* This is the ONE projection that yields the SAME OID whether the object is
|
|
45
|
+
* pre- or post-attestation: attaching an `attestation` (or `signature`,
|
|
46
|
+
* `ml_dsa_signature`, `signature_key_id`, `signature_algorithm`) after hashing
|
|
47
|
+
* is stripped back out here, so `cdroOid(obj)` is invariant across signing.
|
|
48
|
+
*
|
|
49
|
+
* It is FROZEN so no caller can mutate the normative set at runtime.
|
|
50
|
+
*/
|
|
51
|
+
export const CDRO_ENVELOPE_FIELDS: readonly string[] = Object.freeze([
|
|
52
|
+
'oid',
|
|
53
|
+
'signature',
|
|
54
|
+
'ml_dsa_signature',
|
|
55
|
+
'signature_key_id',
|
|
56
|
+
'signature_algorithm',
|
|
57
|
+
'attestation',
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
const CDRO_ENVELOPE_FIELD_SET: ReadonlySet<string> = new Set(CDRO_ENVELOPE_FIELDS)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the OID content core of a full CDRO: the object with EXACTLY the six
|
|
64
|
+
* detached-signature / envelope fields in `CDRO_ENVELOPE_FIELDS` removed at
|
|
65
|
+
* the top level, and everything else kept.
|
|
66
|
+
*
|
|
67
|
+
* This is the mechanism that makes the L4 `authority` block, the L3 lineage
|
|
68
|
+
* edges (`prev`, `links`, `supersedes`), the propagating `sensitivity` tier,
|
|
69
|
+
* and `gap_version` tamper-evident: they are hashed into identity by
|
|
70
|
+
* construction, so a field cannot be added, stripped, re-pointed, or
|
|
71
|
+
* downgraded without producing a different OID (and invalidating the
|
|
72
|
+
* signature, which is computed over these same bytes). Because `prev`/`links`/
|
|
73
|
+
* `supersedes` OIDs are inside the hash, a node's OID transitively commits its
|
|
74
|
+
* whole reachable history (the Merkle-DAG "head proves history" property).
|
|
75
|
+
*
|
|
76
|
+
* Returns a plain object suitable for `canonicalize` / `oidOf`.
|
|
77
|
+
*/
|
|
78
|
+
export function cdroContentCore(cdro: unknown): Record<string, unknown> {
|
|
79
|
+
if (cdro === null || typeof cdro !== 'object' || Array.isArray(cdro)) {
|
|
80
|
+
throw new TypeError('cdroContentCore: argument must be a CDRO object')
|
|
81
|
+
}
|
|
82
|
+
const core: Record<string, unknown> = {}
|
|
83
|
+
for (const [k, v] of Object.entries(cdro as Record<string, unknown>)) {
|
|
84
|
+
if (CDRO_ENVELOPE_FIELD_SET.has(k)) continue
|
|
85
|
+
core[k] = v
|
|
86
|
+
}
|
|
87
|
+
return core
|
|
88
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/ed25519-browser.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe Ed25519 verification. NO node:crypto.
|
|
5
|
+
*
|
|
6
|
+
* Primary path: WebCrypto (`globalThis.crypto.subtle`), which uses RFC 8032
|
|
7
|
+
* cofactored verification — the SAME rule OpenSSL/node:crypto uses on the node
|
|
8
|
+
* default entry, so the browser verifier accepts/rejects exactly what the node
|
|
9
|
+
* verifier does for every real signature.
|
|
10
|
+
*
|
|
11
|
+
* Fallback path: `@noble/curves` ed25519 (pure JS), used only when WebCrypto
|
|
12
|
+
* Ed25519 is unavailable (older embedded webviews / Chrome extensions below the
|
|
13
|
+
* WebCrypto-Ed25519 support floor, ~Chrome 137). @noble defaults to ZIP-215
|
|
14
|
+
* verification rules, which agree with cofactored on every well-formed
|
|
15
|
+
* signature and can differ only on deliberately malformed/malleable points that
|
|
16
|
+
* real signers never produce (see ed25519.ts for the same note). SynOI signs
|
|
17
|
+
* canonically, so the fallback is behavior-equivalent for legitimate inputs.
|
|
18
|
+
*
|
|
19
|
+
* ASYNC: WebCrypto verify is Promise-based, so this returns a Promise<boolean>.
|
|
20
|
+
* The browser attestation verifier is async as a result; the node path stays
|
|
21
|
+
* synchronous and unchanged.
|
|
22
|
+
*
|
|
23
|
+
* Returns false (never throws) on any malformed input or verification failure,
|
|
24
|
+
* matching the node `verifyEd25519` contract, so callers get a clean boolean.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ed25519 } from '@noble/curves/ed25519.js'
|
|
28
|
+
|
|
29
|
+
// Fixed DER prefix for an Ed25519 SubjectPublicKeyInfo (RFC 8410):
|
|
30
|
+
// SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING (32 raw bytes) }
|
|
31
|
+
// A plain Uint8Array literal (NOT Buffer, which is undefined in browsers). Used
|
|
32
|
+
// only for the SPKI import fallback below.
|
|
33
|
+
const ED25519_SPKI_PREFIX = new Uint8Array([
|
|
34
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const ED25519_ALG = { name: 'Ed25519' } as const
|
|
38
|
+
|
|
39
|
+
// Return type is inferred (webcrypto.CryptoKey); the bare `CryptoKey` global is
|
|
40
|
+
// not declared by @types/node ^20, so it is deliberately left un-annotated.
|
|
41
|
+
async function importPublicKey(publicKeyRaw: Uint8Array) {
|
|
42
|
+
const subtle = globalThis.crypto.subtle
|
|
43
|
+
try {
|
|
44
|
+
// Preferred: raw 32-byte import (Secure Curves spec).
|
|
45
|
+
return await subtle.importKey('raw', publicKeyRaw, ED25519_ALG, false, ['verify'])
|
|
46
|
+
} catch {
|
|
47
|
+
// Some engines only accept SPKI for Ed25519 import; wrap the raw key.
|
|
48
|
+
const spki = new Uint8Array(ED25519_SPKI_PREFIX.length + publicKeyRaw.length)
|
|
49
|
+
spki.set(ED25519_SPKI_PREFIX, 0)
|
|
50
|
+
spki.set(publicKeyRaw, ED25519_SPKI_PREFIX.length)
|
|
51
|
+
return await subtle.importKey('spki', spki, ED25519_ALG, false, ['verify'])
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Verify a raw 64-byte Ed25519 signature over `message` against a raw 32-byte
|
|
57
|
+
* public key. Prefers WebCrypto (cofactored, matching the node path); falls
|
|
58
|
+
* back to @noble/curves only when WebCrypto Ed25519 is not supported. Returns
|
|
59
|
+
* false on any malformed input or verification failure; never throws.
|
|
60
|
+
*/
|
|
61
|
+
export async function verifyEd25519Browser(
|
|
62
|
+
signature: Uint8Array,
|
|
63
|
+
message: Uint8Array,
|
|
64
|
+
publicKeyRaw: Uint8Array,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
if (publicKeyRaw.length !== 32) return false
|
|
67
|
+
|
|
68
|
+
const subtle = globalThis.crypto?.subtle
|
|
69
|
+
if (subtle) {
|
|
70
|
+
try {
|
|
71
|
+
const key = await importPublicKey(publicKeyRaw)
|
|
72
|
+
return await subtle.verify(ED25519_ALG, key, signature, message)
|
|
73
|
+
} catch {
|
|
74
|
+
// Fall through to @noble. A WebCrypto throw here is either an
|
|
75
|
+
// unsupported-algorithm signal or a malformed input; @noble resolves both
|
|
76
|
+
// correctly (a real bad signature/key verifies as false there too).
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
return ed25519.verify(signature, message, publicKeyRaw)
|
|
82
|
+
} catch {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/mldsa-browser.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe ML-DSA-65 (FIPS 204) verification. NO node:crypto.
|
|
5
|
+
*
|
|
6
|
+
* Browsers ship no native ML-DSA, so there is only one path here: the pure-JS
|
|
7
|
+
* `@noble/post-quantum` implementation, which is fully browser-safe. The node
|
|
8
|
+
* default entry (mldsa.ts) uses a native OpenSSL fast path with this same
|
|
9
|
+
* @noble impl as its fallback, so both entries are byte-for-byte interoperable.
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT: import `ml_dsa65` DIRECTLY from `@noble/post-quantum/ml-dsa.js`,
|
|
12
|
+
* NOT from ./mldsa.ts — that module statically imports `node:crypto` (for its
|
|
13
|
+
* native fast path and the globalThis.crypto polyfill), which would poison a
|
|
14
|
+
* browser bundle. No globalThis.crypto polyfill is needed here: browsers,
|
|
15
|
+
* workers, and MV3 service workers all provide it natively, and ml_dsa65.verify
|
|
16
|
+
* is deterministic (it never calls getRandomValues; only keygen/sign do).
|
|
17
|
+
*
|
|
18
|
+
* Verification is SYNCHRONOUS. Returns false (never throws) on any malformed
|
|
19
|
+
* input or verification failure, matching the node `verifyMlDsa65` contract.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Verify a raw ML-DSA-65 signature over `message` against a raw 1952-byte
|
|
26
|
+
* public key using @noble/post-quantum. Returns false on any malformed input or
|
|
27
|
+
* verification failure; never throws.
|
|
28
|
+
*/
|
|
29
|
+
export function verifyMlDsa65Browser(
|
|
30
|
+
signature: Uint8Array,
|
|
31
|
+
message: Uint8Array,
|
|
32
|
+
publicKeyRaw: Uint8Array,
|
|
33
|
+
): boolean {
|
|
34
|
+
try {
|
|
35
|
+
return ml_dsa65.verify(signature, message, publicKeyRaw)
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synoi/sraid — internal/sha256-browser.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe SHA-256 via WebCrypto (`globalThis.crypto.subtle.digest`). NO
|
|
5
|
+
* node:crypto. Used by the browser OID helpers in verify-browser.ts.
|
|
6
|
+
*
|
|
7
|
+
* ASYNC: subtle.digest is Promise-based, so the browser OID helpers are async
|
|
8
|
+
* (the node oid.ts helpers stay synchronous via node:crypto createHash and are
|
|
9
|
+
* unchanged). SHA-256 is byte-identical across any conformant implementation,
|
|
10
|
+
* so a browser-computed OID equals the node-computed one for the same input.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function toHex(bytes: Uint8Array): string {
|
|
14
|
+
let out = ''
|
|
15
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
16
|
+
out += (bytes[i] as number).toString(16).padStart(2, '0')
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute `sha256:` + lowercase hex of SHA-256(bytes) using WebCrypto. Mirrors
|
|
23
|
+
* the `sha256:`-prefixed output of the node oid.ts helpers.
|
|
24
|
+
*/
|
|
25
|
+
export async function sha256HexPrefixed(bytes: Uint8Array): Promise<string> {
|
|
26
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)
|
|
27
|
+
return 'sha256:' + toHex(new Uint8Array(digest))
|
|
28
|
+
}
|
package/src/lineage.ts
CHANGED
|
Binary file
|