kxco-verify 1.2.0 → 1.2.2
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/CHANGELOG.md +83 -71
- package/LICENSE +202 -202
- package/README.md +275 -252
- package/package.json +87 -87
- package/src/fetch.d.ts +28 -28
- package/src/fetch.js +100 -100
- package/src/index.d.ts +57 -57
- package/src/index.js +212 -212
- package/src/parse.d.ts +31 -31
- package/src/parse.js +116 -116
- package/src/verify.d.ts +24 -24
- package/src/verify.js +103 -103
package/src/index.js
CHANGED
|
@@ -1,212 +1,212 @@
|
|
|
1
|
-
// kxco-verify — public entry point.
|
|
2
|
-
//
|
|
3
|
-
// What this library is for:
|
|
4
|
-
// Given a URL (or an attestation manifest you already have), determine
|
|
5
|
-
// whether a site's post-quantum deploy attestation is mathematically valid.
|
|
6
|
-
//
|
|
7
|
-
// What it deliberately does NOT do:
|
|
8
|
-
// - Endorse any site. A "valid" result means "the site signed its own
|
|
9
|
-
// manifest with a key it published" — nothing about who the site is or
|
|
10
|
-
// whether its content is trustworthy.
|
|
11
|
-
// - Maintain a registry of approved (domain, kid) pairs. That is a future
|
|
12
|
-
// phase of kxco-post-quantum / verify.kxco.ai and not in this library.
|
|
13
|
-
// - Speak any algorithm other than ML-DSA-65 with hex encoding in this
|
|
14
|
-
// release. SLH-DSA-128s and hybrid envelopes are deferred.
|
|
15
|
-
//
|
|
16
|
-
// The result envelope is intentionally three-valued so the UI can distinguish
|
|
17
|
-
// signature failure from in-flight key rotation:
|
|
18
|
-
//
|
|
19
|
-
// "valid" — signature checks against the manifest's declared kid AND
|
|
20
|
-
// that kid matches the live well-known pubkey endpoint
|
|
21
|
-
// "rotated" — signature checks against the manifest's declared kid, BUT
|
|
22
|
-
// the live well-known endpoint now serves a different kid
|
|
23
|
-
// (interpret as: site is in the middle of a key rotation;
|
|
24
|
-
// ask the user to retry shortly)
|
|
25
|
-
// "invalid" — signature does not check against the manifest-declared key
|
|
26
|
-
// (interpret as: signature is forged, manifest was tampered
|
|
27
|
-
// with, or the publisher's signing pipeline is broken)
|
|
28
|
-
//
|
|
29
|
-
// All other failure modes (network, malformed JSON, unsupported algorithm)
|
|
30
|
-
// surface as { state: "error", error: {...} }.
|
|
31
|
-
|
|
32
|
-
import { parseManifest } from './parse.js'
|
|
33
|
-
import { verifySignature,
|
|
34
|
-
computeKid,
|
|
35
|
-
hexEquals } from './verify.js'
|
|
36
|
-
import { getJsonBody } from './fetch.js'
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @typedef {'valid'|'rotated'|'invalid'|'error'} VerifyState
|
|
40
|
-
*
|
|
41
|
-
* @typedef {Object} VerifyResult
|
|
42
|
-
* @property {VerifyState} state
|
|
43
|
-
* @property {string=} algorithm — e.g. "ML-DSA-65"
|
|
44
|
-
* @property {string=} manifestKid — kid the manifest declared
|
|
45
|
-
* @property {string=} livePubkeyKid — kid currently served at the well-known endpoint (only present when fetched)
|
|
46
|
-
* @property {string=} site — site identifier as declared by the manifest
|
|
47
|
-
* @property {object=} deployment — opaque deployment metadata from the manifest
|
|
48
|
-
* @property {object=} manifestRaw — full parsed manifest JSON (for UI display)
|
|
49
|
-
* @property {{ kind: string, code: string, message: string, [k: string]: any }=} error
|
|
50
|
-
* @property {string=} attestationUrl
|
|
51
|
-
* @property {string=} pubkeyUrl
|
|
52
|
-
* @property {number=} verifiedAtMs — Date.now() at the moment verification finished
|
|
53
|
-
*/
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Verify an attestation manifest you already have in hand (e.g. paste from
|
|
57
|
-
* the user, or already fetched). Does NOT contact the live well-known
|
|
58
|
-
* endpoint — so the result is at best "valid" or "invalid" but never "rotated".
|
|
59
|
-
*
|
|
60
|
-
* @param {string|object} manifestBody — raw JSON body or parsed object
|
|
61
|
-
* @returns {Promise<VerifyResult>}
|
|
62
|
-
*/
|
|
63
|
-
export async function verifyManifest(manifestBody) {
|
|
64
|
-
const parsed = parseManifest(manifestBody)
|
|
65
|
-
if (!parsed.ok) return { state: 'error', error: parsed.error, verifiedAtMs: Date.now() }
|
|
66
|
-
const m = parsed.manifest
|
|
67
|
-
|
|
68
|
-
// The publisher's own consistency check: the kid embedded inside the
|
|
69
|
-
// publicKey block must match what manifest.kid says, AND must match the
|
|
70
|
-
// SHA-256 fingerprint of the pubkey bytes. If either disagrees, the
|
|
71
|
-
// manifest is internally inconsistent — treat as invalid before we even
|
|
72
|
-
// run the slow signature math.
|
|
73
|
-
const recomputedKid = await computeKid(m.publicKeyHex)
|
|
74
|
-
if (!hexEquals(recomputedKid, m.kid) || !hexEquals(recomputedKid, m.publicKeyKid)) {
|
|
75
|
-
return {
|
|
76
|
-
state: 'invalid',
|
|
77
|
-
algorithm: m.alg,
|
|
78
|
-
manifestKid: m.kid,
|
|
79
|
-
site: m.site,
|
|
80
|
-
manifestRaw: m.raw,
|
|
81
|
-
error: {
|
|
82
|
-
kind: 'consistency',
|
|
83
|
-
code: 'kid_mismatch_internal',
|
|
84
|
-
message: `manifest.kid (${m.kid}) and/or publicKey.kid (${m.publicKeyKid}) disagree with SHA-256(publicKey) (${recomputedKid})`,
|
|
85
|
-
},
|
|
86
|
-
verifiedAtMs: Date.now(),
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const sigValid = verifySignature(m.publicKeyHex, m.signedMessage, m.signatureHex)
|
|
91
|
-
return {
|
|
92
|
-
state: sigValid ? 'valid' : 'invalid',
|
|
93
|
-
algorithm: m.alg,
|
|
94
|
-
manifestKid: m.kid,
|
|
95
|
-
site: m.site,
|
|
96
|
-
deployment: m.deployment,
|
|
97
|
-
manifestRaw: m.raw,
|
|
98
|
-
error: sigValid ? undefined : {
|
|
99
|
-
kind: 'signature',
|
|
100
|
-
code: 'invalid_signature',
|
|
101
|
-
message: 'signature does not verify under the manifest-declared public key',
|
|
102
|
-
},
|
|
103
|
-
verifiedAtMs: Date.now(),
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Verify an attestation by URL.
|
|
109
|
-
*
|
|
110
|
-
* 1. Fetch the attestation URL.
|
|
111
|
-
* 2. Parse + math-verify it (as verifyManifest does).
|
|
112
|
-
* 3. If the manifest declared `publicKey.pinAt`, also fetch that endpoint
|
|
113
|
-
* and compare its kid to the manifest's. A mismatch downgrades a "valid"
|
|
114
|
-
* result to "rotated" — the signature checks against the kid the
|
|
115
|
-
* manifest declared, but the live endpoint now serves a different kid.
|
|
116
|
-
*
|
|
117
|
-
* @param {string} attestationUrl
|
|
118
|
-
* @param {{ timeoutMs?: number, maxBytes?: number, fetchImpl?: typeof fetch, skipLivePubkey?: boolean }} [opts]
|
|
119
|
-
* @returns {Promise<VerifyResult>}
|
|
120
|
-
*/
|
|
121
|
-
export async function verifyUrl(attestationUrl, opts = {}) {
|
|
122
|
-
const t0 = Date.now()
|
|
123
|
-
const fetched = await getJsonBody(attestationUrl, opts)
|
|
124
|
-
if (!fetched.ok) {
|
|
125
|
-
return { state: 'error', error: fetched.error, attestationUrl, verifiedAtMs: Date.now() }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const result = await verifyManifest(fetched.body)
|
|
129
|
-
result.attestationUrl = attestationUrl
|
|
130
|
-
|
|
131
|
-
// Math failed already; no benefit in fetching the live pubkey.
|
|
132
|
-
if (result.state !== 'valid' || opts.skipLivePubkey) {
|
|
133
|
-
result.verifiedAtMs = Date.now()
|
|
134
|
-
return result
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Resolve pinAt against the attestation URL's origin.
|
|
138
|
-
const pinAt = result.manifestRaw?.publicKey?.pinAt
|
|
139
|
-
if (typeof pinAt !== 'string' || !pinAt) {
|
|
140
|
-
// No pinAt → publisher didn't tell us where the live pubkey is, so we
|
|
141
|
-
// cannot detect rotation. Return valid as-is and let the UI explain.
|
|
142
|
-
result.verifiedAtMs = Date.now()
|
|
143
|
-
return result
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
let pubkeyUrl
|
|
147
|
-
try {
|
|
148
|
-
pubkeyUrl = new URL(pinAt, attestationUrl).toString()
|
|
149
|
-
} catch (err) {
|
|
150
|
-
result.error = { kind: 'consistency', code: 'invalid_pinAt', message: `manifest.publicKey.pinAt is not a resolvable URL: ${err.message}` }
|
|
151
|
-
result.state = 'invalid'
|
|
152
|
-
result.verifiedAtMs = Date.now()
|
|
153
|
-
return result
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const livePk = await getJsonBody(pubkeyUrl, opts)
|
|
157
|
-
if (!livePk.ok) {
|
|
158
|
-
// Couldn't fetch the live pubkey. Don't downgrade — the math succeeded.
|
|
159
|
-
// Tell the UI we couldn't confirm rotation status.
|
|
160
|
-
result.pubkeyUrl = pubkeyUrl
|
|
161
|
-
result.error = { kind: 'fetch', code: livePk.error.code, message: `could not fetch live pubkey at ${pubkeyUrl}: ${livePk.error.message}`, soft: true }
|
|
162
|
-
result.verifiedAtMs = Date.now()
|
|
163
|
-
return result
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
let liveJson
|
|
167
|
-
try { liveJson = JSON.parse(livePk.body) }
|
|
168
|
-
catch (err) {
|
|
169
|
-
result.pubkeyUrl = pubkeyUrl
|
|
170
|
-
result.error = { kind: 'parse', code: 'invalid_live_pubkey_json', message: `live pubkey body is not JSON: ${err.message}`, soft: true }
|
|
171
|
-
result.verifiedAtMs = Date.now()
|
|
172
|
-
return result
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Two ways the publisher may serve the kid: explicit "kid" field, or we
|
|
176
|
-
// recompute from the publicKey hex. Prefer recompute as the source of truth.
|
|
177
|
-
const livePubkeyHex = typeof liveJson.publicKey === 'string'
|
|
178
|
-
? liveJson.publicKey
|
|
179
|
-
: (typeof liveJson?.value === 'string' ? liveJson.value : null)
|
|
180
|
-
if (!livePubkeyHex) {
|
|
181
|
-
result.pubkeyUrl = pubkeyUrl
|
|
182
|
-
result.error = { kind: 'consistency', code: 'live_pubkey_missing', message: 'live well-known endpoint did not return a publicKey field', soft: true }
|
|
183
|
-
result.verifiedAtMs = Date.now()
|
|
184
|
-
return result
|
|
185
|
-
}
|
|
186
|
-
const liveKid = await computeKid(livePubkeyHex)
|
|
187
|
-
result.pubkeyUrl = pubkeyUrl
|
|
188
|
-
result.livePubkeyKid = liveKid
|
|
189
|
-
|
|
190
|
-
if (!hexEquals(liveKid, result.manifestKid)) {
|
|
191
|
-
// Signature checked against manifest.kid but the well-known now serves
|
|
192
|
-
// a different kid. This is the rotation signal.
|
|
193
|
-
result.state = 'rotated'
|
|
194
|
-
result.error = {
|
|
195
|
-
kind: 'rotation',
|
|
196
|
-
code: 'live_kid_mismatch',
|
|
197
|
-
message: `signature is valid for kid ${result.manifestKid}, but the live well-known endpoint now serves kid ${liveKid}. The site is likely mid key-rotation; retry shortly.`,
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
result.verifiedAtMs = Date.now()
|
|
202
|
-
return result
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Re-export the lower-level helpers for users who want to compose.
|
|
206
|
-
export { parseManifest } from './parse.js'
|
|
207
|
-
export { verifySignature,
|
|
208
|
-
computeKid,
|
|
209
|
-
hexToBytes,
|
|
210
|
-
bytesToHex,
|
|
211
|
-
hexEquals } from './verify.js'
|
|
212
|
-
export { getJsonBody } from './fetch.js'
|
|
1
|
+
// kxco-verify — public entry point.
|
|
2
|
+
//
|
|
3
|
+
// What this library is for:
|
|
4
|
+
// Given a URL (or an attestation manifest you already have), determine
|
|
5
|
+
// whether a site's post-quantum deploy attestation is mathematically valid.
|
|
6
|
+
//
|
|
7
|
+
// What it deliberately does NOT do:
|
|
8
|
+
// - Endorse any site. A "valid" result means "the site signed its own
|
|
9
|
+
// manifest with a key it published" — nothing about who the site is or
|
|
10
|
+
// whether its content is trustworthy.
|
|
11
|
+
// - Maintain a registry of approved (domain, kid) pairs. That is a future
|
|
12
|
+
// phase of kxco-post-quantum / verify.kxco.ai and not in this library.
|
|
13
|
+
// - Speak any algorithm other than ML-DSA-65 with hex encoding in this
|
|
14
|
+
// release. SLH-DSA-128s and hybrid envelopes are deferred.
|
|
15
|
+
//
|
|
16
|
+
// The result envelope is intentionally three-valued so the UI can distinguish
|
|
17
|
+
// signature failure from in-flight key rotation:
|
|
18
|
+
//
|
|
19
|
+
// "valid" — signature checks against the manifest's declared kid AND
|
|
20
|
+
// that kid matches the live well-known pubkey endpoint
|
|
21
|
+
// "rotated" — signature checks against the manifest's declared kid, BUT
|
|
22
|
+
// the live well-known endpoint now serves a different kid
|
|
23
|
+
// (interpret as: site is in the middle of a key rotation;
|
|
24
|
+
// ask the user to retry shortly)
|
|
25
|
+
// "invalid" — signature does not check against the manifest-declared key
|
|
26
|
+
// (interpret as: signature is forged, manifest was tampered
|
|
27
|
+
// with, or the publisher's signing pipeline is broken)
|
|
28
|
+
//
|
|
29
|
+
// All other failure modes (network, malformed JSON, unsupported algorithm)
|
|
30
|
+
// surface as { state: "error", error: {...} }.
|
|
31
|
+
|
|
32
|
+
import { parseManifest } from './parse.js'
|
|
33
|
+
import { verifySignature,
|
|
34
|
+
computeKid,
|
|
35
|
+
hexEquals } from './verify.js'
|
|
36
|
+
import { getJsonBody } from './fetch.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {'valid'|'rotated'|'invalid'|'error'} VerifyState
|
|
40
|
+
*
|
|
41
|
+
* @typedef {Object} VerifyResult
|
|
42
|
+
* @property {VerifyState} state
|
|
43
|
+
* @property {string=} algorithm — e.g. "ML-DSA-65"
|
|
44
|
+
* @property {string=} manifestKid — kid the manifest declared
|
|
45
|
+
* @property {string=} livePubkeyKid — kid currently served at the well-known endpoint (only present when fetched)
|
|
46
|
+
* @property {string=} site — site identifier as declared by the manifest
|
|
47
|
+
* @property {object=} deployment — opaque deployment metadata from the manifest
|
|
48
|
+
* @property {object=} manifestRaw — full parsed manifest JSON (for UI display)
|
|
49
|
+
* @property {{ kind: string, code: string, message: string, [k: string]: any }=} error
|
|
50
|
+
* @property {string=} attestationUrl
|
|
51
|
+
* @property {string=} pubkeyUrl
|
|
52
|
+
* @property {number=} verifiedAtMs — Date.now() at the moment verification finished
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Verify an attestation manifest you already have in hand (e.g. paste from
|
|
57
|
+
* the user, or already fetched). Does NOT contact the live well-known
|
|
58
|
+
* endpoint — so the result is at best "valid" or "invalid" but never "rotated".
|
|
59
|
+
*
|
|
60
|
+
* @param {string|object} manifestBody — raw JSON body or parsed object
|
|
61
|
+
* @returns {Promise<VerifyResult>}
|
|
62
|
+
*/
|
|
63
|
+
export async function verifyManifest(manifestBody) {
|
|
64
|
+
const parsed = parseManifest(manifestBody)
|
|
65
|
+
if (!parsed.ok) return { state: 'error', error: parsed.error, verifiedAtMs: Date.now() }
|
|
66
|
+
const m = parsed.manifest
|
|
67
|
+
|
|
68
|
+
// The publisher's own consistency check: the kid embedded inside the
|
|
69
|
+
// publicKey block must match what manifest.kid says, AND must match the
|
|
70
|
+
// SHA-256 fingerprint of the pubkey bytes. If either disagrees, the
|
|
71
|
+
// manifest is internally inconsistent — treat as invalid before we even
|
|
72
|
+
// run the slow signature math.
|
|
73
|
+
const recomputedKid = await computeKid(m.publicKeyHex)
|
|
74
|
+
if (!hexEquals(recomputedKid, m.kid) || !hexEquals(recomputedKid, m.publicKeyKid)) {
|
|
75
|
+
return {
|
|
76
|
+
state: 'invalid',
|
|
77
|
+
algorithm: m.alg,
|
|
78
|
+
manifestKid: m.kid,
|
|
79
|
+
site: m.site,
|
|
80
|
+
manifestRaw: m.raw,
|
|
81
|
+
error: {
|
|
82
|
+
kind: 'consistency',
|
|
83
|
+
code: 'kid_mismatch_internal',
|
|
84
|
+
message: `manifest.kid (${m.kid}) and/or publicKey.kid (${m.publicKeyKid}) disagree with SHA-256(publicKey) (${recomputedKid})`,
|
|
85
|
+
},
|
|
86
|
+
verifiedAtMs: Date.now(),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sigValid = verifySignature(m.publicKeyHex, m.signedMessage, m.signatureHex)
|
|
91
|
+
return {
|
|
92
|
+
state: sigValid ? 'valid' : 'invalid',
|
|
93
|
+
algorithm: m.alg,
|
|
94
|
+
manifestKid: m.kid,
|
|
95
|
+
site: m.site,
|
|
96
|
+
deployment: m.deployment,
|
|
97
|
+
manifestRaw: m.raw,
|
|
98
|
+
error: sigValid ? undefined : {
|
|
99
|
+
kind: 'signature',
|
|
100
|
+
code: 'invalid_signature',
|
|
101
|
+
message: 'signature does not verify under the manifest-declared public key',
|
|
102
|
+
},
|
|
103
|
+
verifiedAtMs: Date.now(),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Verify an attestation by URL.
|
|
109
|
+
*
|
|
110
|
+
* 1. Fetch the attestation URL.
|
|
111
|
+
* 2. Parse + math-verify it (as verifyManifest does).
|
|
112
|
+
* 3. If the manifest declared `publicKey.pinAt`, also fetch that endpoint
|
|
113
|
+
* and compare its kid to the manifest's. A mismatch downgrades a "valid"
|
|
114
|
+
* result to "rotated" — the signature checks against the kid the
|
|
115
|
+
* manifest declared, but the live endpoint now serves a different kid.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} attestationUrl
|
|
118
|
+
* @param {{ timeoutMs?: number, maxBytes?: number, fetchImpl?: typeof fetch, skipLivePubkey?: boolean }} [opts]
|
|
119
|
+
* @returns {Promise<VerifyResult>}
|
|
120
|
+
*/
|
|
121
|
+
export async function verifyUrl(attestationUrl, opts = {}) {
|
|
122
|
+
const t0 = Date.now()
|
|
123
|
+
const fetched = await getJsonBody(attestationUrl, opts)
|
|
124
|
+
if (!fetched.ok) {
|
|
125
|
+
return { state: 'error', error: fetched.error, attestationUrl, verifiedAtMs: Date.now() }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = await verifyManifest(fetched.body)
|
|
129
|
+
result.attestationUrl = attestationUrl
|
|
130
|
+
|
|
131
|
+
// Math failed already; no benefit in fetching the live pubkey.
|
|
132
|
+
if (result.state !== 'valid' || opts.skipLivePubkey) {
|
|
133
|
+
result.verifiedAtMs = Date.now()
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve pinAt against the attestation URL's origin.
|
|
138
|
+
const pinAt = result.manifestRaw?.publicKey?.pinAt
|
|
139
|
+
if (typeof pinAt !== 'string' || !pinAt) {
|
|
140
|
+
// No pinAt → publisher didn't tell us where the live pubkey is, so we
|
|
141
|
+
// cannot detect rotation. Return valid as-is and let the UI explain.
|
|
142
|
+
result.verifiedAtMs = Date.now()
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let pubkeyUrl
|
|
147
|
+
try {
|
|
148
|
+
pubkeyUrl = new URL(pinAt, attestationUrl).toString()
|
|
149
|
+
} catch (err) {
|
|
150
|
+
result.error = { kind: 'consistency', code: 'invalid_pinAt', message: `manifest.publicKey.pinAt is not a resolvable URL: ${err.message}` }
|
|
151
|
+
result.state = 'invalid'
|
|
152
|
+
result.verifiedAtMs = Date.now()
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const livePk = await getJsonBody(pubkeyUrl, opts)
|
|
157
|
+
if (!livePk.ok) {
|
|
158
|
+
// Couldn't fetch the live pubkey. Don't downgrade — the math succeeded.
|
|
159
|
+
// Tell the UI we couldn't confirm rotation status.
|
|
160
|
+
result.pubkeyUrl = pubkeyUrl
|
|
161
|
+
result.error = { kind: 'fetch', code: livePk.error.code, message: `could not fetch live pubkey at ${pubkeyUrl}: ${livePk.error.message}`, soft: true }
|
|
162
|
+
result.verifiedAtMs = Date.now()
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let liveJson
|
|
167
|
+
try { liveJson = JSON.parse(livePk.body) }
|
|
168
|
+
catch (err) {
|
|
169
|
+
result.pubkeyUrl = pubkeyUrl
|
|
170
|
+
result.error = { kind: 'parse', code: 'invalid_live_pubkey_json', message: `live pubkey body is not JSON: ${err.message}`, soft: true }
|
|
171
|
+
result.verifiedAtMs = Date.now()
|
|
172
|
+
return result
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Two ways the publisher may serve the kid: explicit "kid" field, or we
|
|
176
|
+
// recompute from the publicKey hex. Prefer recompute as the source of truth.
|
|
177
|
+
const livePubkeyHex = typeof liveJson.publicKey === 'string'
|
|
178
|
+
? liveJson.publicKey
|
|
179
|
+
: (typeof liveJson?.value === 'string' ? liveJson.value : null)
|
|
180
|
+
if (!livePubkeyHex) {
|
|
181
|
+
result.pubkeyUrl = pubkeyUrl
|
|
182
|
+
result.error = { kind: 'consistency', code: 'live_pubkey_missing', message: 'live well-known endpoint did not return a publicKey field', soft: true }
|
|
183
|
+
result.verifiedAtMs = Date.now()
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
const liveKid = await computeKid(livePubkeyHex)
|
|
187
|
+
result.pubkeyUrl = pubkeyUrl
|
|
188
|
+
result.livePubkeyKid = liveKid
|
|
189
|
+
|
|
190
|
+
if (!hexEquals(liveKid, result.manifestKid)) {
|
|
191
|
+
// Signature checked against manifest.kid but the well-known now serves
|
|
192
|
+
// a different kid. This is the rotation signal.
|
|
193
|
+
result.state = 'rotated'
|
|
194
|
+
result.error = {
|
|
195
|
+
kind: 'rotation',
|
|
196
|
+
code: 'live_kid_mismatch',
|
|
197
|
+
message: `signature is valid for kid ${result.manifestKid}, but the live well-known endpoint now serves kid ${liveKid}. The site is likely mid key-rotation; retry shortly.`,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
result.verifiedAtMs = Date.now()
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Re-export the lower-level helpers for users who want to compose.
|
|
206
|
+
export { parseManifest } from './parse.js'
|
|
207
|
+
export { verifySignature,
|
|
208
|
+
computeKid,
|
|
209
|
+
hexToBytes,
|
|
210
|
+
bytesToHex,
|
|
211
|
+
hexEquals } from './verify.js'
|
|
212
|
+
export { getJsonBody } from './fetch.js'
|
package/src/parse.d.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
export interface ParsedManifest {
|
|
2
|
-
site: string
|
|
3
|
-
alg: 'ML-DSA-65'
|
|
4
|
-
kid: string
|
|
5
|
-
signedMessage: string
|
|
6
|
-
signatureHex: string
|
|
7
|
-
publicKeyHex: string
|
|
8
|
-
publicKeyKid: string
|
|
9
|
-
pinAt?: string
|
|
10
|
-
deployment?: Record<string, unknown>
|
|
11
|
-
raw: Record<string, unknown>
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface ParseError {
|
|
15
|
-
kind: 'parse'
|
|
16
|
-
code:
|
|
17
|
-
| 'invalid_json'
|
|
18
|
-
| 'invalid_input'
|
|
19
|
-
| 'missing_field'
|
|
20
|
-
| 'invalid_field'
|
|
21
|
-
| 'unsupported_algorithm'
|
|
22
|
-
| 'unsupported_encoding'
|
|
23
|
-
message: string
|
|
24
|
-
field?: string
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type ParseResult =
|
|
28
|
-
| { ok: true; manifest: ParsedManifest }
|
|
29
|
-
| { ok: false; error: ParseError }
|
|
30
|
-
|
|
31
|
-
export function parseManifest(input: string | object): ParseResult
|
|
1
|
+
export interface ParsedManifest {
|
|
2
|
+
site: string
|
|
3
|
+
alg: 'ML-DSA-65'
|
|
4
|
+
kid: string
|
|
5
|
+
signedMessage: string
|
|
6
|
+
signatureHex: string
|
|
7
|
+
publicKeyHex: string
|
|
8
|
+
publicKeyKid: string
|
|
9
|
+
pinAt?: string
|
|
10
|
+
deployment?: Record<string, unknown>
|
|
11
|
+
raw: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ParseError {
|
|
15
|
+
kind: 'parse'
|
|
16
|
+
code:
|
|
17
|
+
| 'invalid_json'
|
|
18
|
+
| 'invalid_input'
|
|
19
|
+
| 'missing_field'
|
|
20
|
+
| 'invalid_field'
|
|
21
|
+
| 'unsupported_algorithm'
|
|
22
|
+
| 'unsupported_encoding'
|
|
23
|
+
message: string
|
|
24
|
+
field?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ParseResult =
|
|
28
|
+
| { ok: true; manifest: ParsedManifest }
|
|
29
|
+
| { ok: false; error: ParseError }
|
|
30
|
+
|
|
31
|
+
export function parseManifest(input: string | object): ParseResult
|