ust-protocol 1.0.0-rc.1 → 1.0.0-rc.3
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 +1 -1
- package/index.mjs +54 -17
- package/package.json +1 -1
package/README.md
CHANGED
package/index.mjs
CHANGED
|
@@ -28,11 +28,14 @@ export const Hbytes = (tag, rawBuf) => sha(Buffer.concat([Buffer.from(tag, 'asci
|
|
|
28
28
|
// ─── §12.2/§17 key_id = H("ust:keylog", raw_pub_bytes) — raw = base64url-decode(pub), NOT plain SHA256(pub)
|
|
29
29
|
export const keyId = (pubB64url) => Hbytes('ust:keylog', Buffer.from(pubB64url, 'base64url'));
|
|
30
30
|
|
|
31
|
-
// ─── §4.4 per-partition hash
|
|
32
|
-
|
|
31
|
+
// ─── §4.4 per-partition hash — UNIFORM: EVERY partition binds its publisher (domain_shard). The partition NAME
|
|
32
|
+
// is carried as a VALUE (`partition:`), never as a key, so it can never overwrite a protocol field (closes the
|
|
33
|
+
// reserved-name collision). The old domain-less `computed` mode is REMOVED: its "cross-engine corroboration"
|
|
34
|
+
// was forgeable (anyone copies a domain-less hash to fake agreement) — real corroboration compares two
|
|
35
|
+
// publisher-BOUND values a layer up. `kind` is now descriptive metadata only and does NOT affect the hash.
|
|
36
|
+
export function partitionHash({ domain_shard, ust_id, name, value, commit }) {
|
|
33
37
|
if (commit !== undefined) return Hbytes('ust:shard', Buffer.from(commit, 'utf8')); // §4.4 private: hash over its commit
|
|
34
|
-
|
|
35
|
-
return H('ust:shard', canon(scope));
|
|
38
|
+
return H('ust:shard', canon({ domain_shard, ust_id, partition: name, value }));
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
// ─── §7 content_hash = H("ust:state", canon({ust, state})) — the unique document descriptor ─────────────
|
|
@@ -55,7 +58,7 @@ export function merkleRoot(contentHashes) {
|
|
|
55
58
|
// ─── §10 PRIVACY — blinded commitment (frame-bound, G23; nonce MUST be fresh & unique per commit, Z2) ────
|
|
56
59
|
// commit = H_shard(canon({domain_shard, ust_id, nonce, <name>: value})) — verifier reproduces from a disclosure.
|
|
57
60
|
export const blindedCommit = ({ domain_shard, ust_id, name, value, nonce }) =>
|
|
58
|
-
H('ust:shard', canon({ domain_shard, ust_id, nonce,
|
|
61
|
+
H('ust:shard', canon({ domain_shard, ust_id, nonce, partition: name, value })); // name as VALUE, non-colliding
|
|
59
62
|
// producer helper: build a blinded PRIVATE partition envelope + its hashes entry (§4.4 private hash = H over commit).
|
|
60
63
|
export function blindPartition(name, value, { domain_shard, ust_id, nonce, kind = 'captured' }) {
|
|
61
64
|
const commit = blindedCommit({ domain_shard, ust_id, name, value, nonce });
|
|
@@ -93,7 +96,7 @@ export function buildState(id, time, data, provenance) {
|
|
|
93
96
|
const hashes = {};
|
|
94
97
|
for (const [name, part] of Object.entries(data))
|
|
95
98
|
hashes[name] = part.commit !== undefined ? partitionHash({ commit: part.commit })
|
|
96
|
-
: partitionHash({ domain_shard: id.domain_shard, ust_id: id.ust_id, name, value: part.value
|
|
99
|
+
: partitionHash({ domain_shard: id.domain_shard, ust_id: id.ust_id, name, value: part.value });
|
|
97
100
|
const state = { id, time, data, hashes };
|
|
98
101
|
if (provenance) state.provenance = provenance;
|
|
99
102
|
return state;
|
|
@@ -111,9 +114,15 @@ export const buildCheckpoint = (id, time, head, frameCount, prev) =>
|
|
|
111
114
|
|
|
112
115
|
// ─── reserved-key sets (§3/§4.2/§17) ─────────────────────────────────────────────────────────────────
|
|
113
116
|
const RESERVED = { transcript: ['ust','state','sig','proof'], state: ['id','time','data','hashes','provenance'],
|
|
114
|
-
id: ['domain_shard','ust_id','key_id','class','parent_ust'], envelope: ['kind','value','privacy','commit','enc']
|
|
117
|
+
id: ['domain_shard','ust_id','key_id','class','parent_ust'], envelope: ['kind','value','privacy','commit','enc'],
|
|
118
|
+
sig: ['alg','key_id','pub','sig'] };
|
|
119
|
+
const RES_PARTITION_NAMES = new Set([...RESERVED.state, ...RESERVED.id, 'partition', 'nonce', '__proto__', 'constructor', 'prototype']);
|
|
120
|
+
const KINDS = ['captured', 'computed'], PRIVACY = ['blinded', 'encrypted'];
|
|
115
121
|
const CLASSES = ['observation','attestation','derivation','genesis','key'];
|
|
116
|
-
|
|
122
|
+
// §6 pinned RFC3339 UTC-Z with VALID RANGES — month 01-12, day 01-31, hour 00-23, min/sec 00-59.
|
|
123
|
+
// Rejects leap seconds (:60) and out-of-range (:99, hour 99) so two conforming verifiers ALWAYS agree (I4).
|
|
124
|
+
// Publishers MUST smear leap seconds to :59 (there is no representable :60).
|
|
125
|
+
const TS = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\dZ$/;
|
|
117
126
|
const USTID = /^ust:\d{8}\.\d{2}(\d{2}(\d{2})?)?$/; // §8
|
|
118
127
|
|
|
119
128
|
// ─── §13 structural bounds — hard ceilings; exceed ⇒ E-BOUNDS ─────────────────────────────────────────
|
|
@@ -141,15 +150,21 @@ export function verify(doc, opts = {}) {
|
|
|
141
150
|
if (typeof doc !== 'object' || doc === null) return bad('E-MALFORMED', 'not an object');
|
|
142
151
|
if (doc.ust === undefined || doc.state === undefined || doc.sig === undefined) return bad('E-MALFORMED', 'missing ust/state/sig');
|
|
143
152
|
if (doc.ust !== '1.0') return bad('E-MALFORMED', 'unknown version ' + doc.ust); // §19 (this verifier is 1.0)
|
|
153
|
+
// §4.1 top-level is EXACTLY {ust,state,sig,proof} — REJECT unknown members (fail-closed). An "ignore unknown"
|
|
154
|
+
// rule would reopen the K1/F2 class: an unsigned member riding next to a VALID verdict.
|
|
155
|
+
for (const k of Object.keys(doc)) if (!RESERVED.transcript.includes(k)) return bad('E-MALFORMED', 'unknown top-level member: ' + k);
|
|
144
156
|
const bnd = checkBounds(doc); if (bnd) return bad('E-BOUNDS', bnd); // §13 bounds
|
|
145
157
|
const st = doc.state;
|
|
146
158
|
for (const k of Object.keys(st)) if (!RESERVED.state.includes(k)) return bad('E-MALFORMED', 'reserved-key: state.' + k);
|
|
147
159
|
if (!st.id || !st.time || !st.data || !st.hashes) return bad('E-MALFORMED', 'state missing id/time/data/hashes');
|
|
148
160
|
for (const k of Object.keys(st.id)) if (!RESERVED.id.includes(k)) return bad('E-MALFORMED', 'reserved-key: id.' + k);
|
|
149
|
-
// §I3 partition
|
|
161
|
+
// §I3 partition NAME non-reserved (defense-in-depth) + envelope-key + kind/privacy registry (§4.4)
|
|
150
162
|
for (const name of Object.keys(st.data)) {
|
|
163
|
+
if (RES_PARTITION_NAMES.has(name)) return bad('E-MALFORMED', 'reserved partition name: ' + name);
|
|
151
164
|
const part = st.data[name];
|
|
152
165
|
for (const k of Object.keys(part)) if (!RESERVED.envelope.includes(k)) return bad('E-MALFORMED', 'reserved-key: data.' + name + '.' + k);
|
|
166
|
+
if (part.privacy === undefined) { if (!KINDS.includes(part.kind)) return bad('E-MALFORMED', 'unknown partition kind: ' + name + '.' + part.kind); }
|
|
167
|
+
else if (!PRIVACY.includes(part.privacy)) return bad('E-MALFORMED', 'unknown privacy mode: ' + name + '.' + part.privacy);
|
|
153
168
|
}
|
|
154
169
|
// step 2 — canonical, content_hash, bijection, per-partition hashes (§14.2, G19, §4.4)
|
|
155
170
|
let S; try { S = signedContent(doc); } catch (e) { return bad('E-CANON', e.detail || 'canon'); }
|
|
@@ -162,7 +177,7 @@ export function verify(doc, opts = {}) {
|
|
|
162
177
|
try {
|
|
163
178
|
recomputed = part.commit !== undefined
|
|
164
179
|
? partitionHash({ commit: part.commit })
|
|
165
|
-
: partitionHash({ domain_shard: st.id.domain_shard, ust_id: st.id.ust_id, name, value: part.value
|
|
180
|
+
: partitionHash({ domain_shard: st.id.domain_shard, ust_id: st.id.ust_id, name, value: part.value });
|
|
166
181
|
} catch (e) { return bad('E-CANON', 'partition canon: ' + name); }
|
|
167
182
|
if (recomputed !== st.hashes[name]) return bad('E-CANON', 'partition hash mismatch: ' + name);
|
|
168
183
|
}
|
|
@@ -179,14 +194,23 @@ export function verify(doc, opts = {}) {
|
|
|
179
194
|
}
|
|
180
195
|
// W3 class-context: a data verify must not accept a key-log/genesis transcript as data
|
|
181
196
|
if (opts.context === 'data' && (st.id.class === 'key' || st.id.class === 'genesis')) return bad('E-MALFORMED', 'class ' + st.id.class + ' not valid in data context (W3)');
|
|
182
|
-
// step 4 — authenticity (§14.4): key_id consistency + strict Ed25519 over S
|
|
197
|
+
// step 4 — authenticity (§14.4): closed sig schema + declared alg + key_id consistency + strict Ed25519 over S
|
|
198
|
+
if (typeof doc.sig !== 'object' || doc.sig === null) return bad('E-SIG', 'sig missing');
|
|
199
|
+
for (const k of Object.keys(doc.sig)) if (!RESERVED.sig.includes(k)) return bad('E-SIG', 'unknown sig member: ' + k);
|
|
200
|
+
if (doc.sig.alg !== 'Ed25519') return bad('E-SIG', 'sig.alg must be Ed25519');
|
|
183
201
|
if (doc.sig.key_id !== st.id.key_id) return bad('E-SIG', 'sig.key_id != state.id.key_id');
|
|
184
202
|
if (doc.sig.pub === undefined) return bad('E-KEY', 'no carried pub (LIGHT)');
|
|
185
203
|
if (keyId(doc.sig.pub) !== st.id.key_id) return bad('E-SIG', 'key_id != H(ust:keylog, pub)');
|
|
186
204
|
if (!edVerifyStrict(doc.sig.pub, S, doc.sig.sig)) return bad('E-SIG', 'Ed25519 verify failed');
|
|
187
|
-
// step 3 — name authority (
|
|
188
|
-
|
|
189
|
-
|
|
205
|
+
// step 3 — name authority (§14.3): HIGH resolves genesis+key-log; else a PINNED key (TOFU, §3.1) if the caller
|
|
206
|
+
// supplies pinnedKeys — a key NOT in the pin set is INVALID (that is what pinning means); else self-asserted.
|
|
207
|
+
let identity;
|
|
208
|
+
if (opts.genesis) identity = resolveAuthority(doc, opts);
|
|
209
|
+
else if (opts.pinnedKeys) identity = opts.pinnedKeys.includes(st.id.key_id)
|
|
210
|
+
? { strength: 'pinned', status: 'verified' }
|
|
211
|
+
: { error: 'E-KEY', detail: 'key_id not in the pinned set (§3.1 TOFU)' };
|
|
212
|
+
else identity = { strength: 'self-asserted', status: 'verified' };
|
|
213
|
+
if (identity.error) return bad(identity.error, identity.detail); // forked genesis / broken key-log / not pinned
|
|
190
214
|
if (opts.requireAuthoritative && !(identity.strength === 'authoritative' && identity.status === 'verified'))
|
|
191
215
|
return identity.status === 'unavailable'
|
|
192
216
|
? { result: 'INDETERMINATE', reason: 'unavailable', identity, detail: identity.detail } // W1: retry, NOT failure
|
|
@@ -203,7 +227,7 @@ export function verify(doc, opts = {}) {
|
|
|
203
227
|
if (reproduced !== part.commit) return bad('E-COMMIT', 'blinded commit mismatch: ' + name);
|
|
204
228
|
if (part.privacy === 'encrypted' && part.enc && opts.decKeys?.[part.enc.key_id]) {
|
|
205
229
|
const pt = aeadDecrypt(part.enc, opts.decKeys[part.enc.key_id]); // → canon({nonce,<p>:value}) plaintext
|
|
206
|
-
if (pt === null || pt !== canon({ nonce: disc.nonce,
|
|
230
|
+
if (pt === null || pt !== canon({ nonce: disc.nonce, partition: name, value: disc.value })) return bad('E-COMMIT', 'AEAD↔commit mismatch: ' + name);
|
|
207
231
|
}
|
|
208
232
|
disclosed.push(name);
|
|
209
233
|
}
|
|
@@ -218,8 +242,12 @@ export function verify(doc, opts = {}) {
|
|
|
218
242
|
// §14.9 attestation: recompute the Merkle root from constituents (⇒ E-ROOT on mismatch).
|
|
219
243
|
if (st.id.class === 'attestation' && st.provenance?.constituents && st.provenance?.root !== undefined)
|
|
220
244
|
if (merkleRoot(st.provenance.constituents) !== st.provenance.root) return bad('E-ROOT', 'attestation root mismatch');
|
|
221
|
-
|
|
222
|
-
|
|
245
|
+
// §Y3: `domain_shard` is surfaced as `publisher` ONLY at `authoritative` strength; otherwise it is a
|
|
246
|
+
// self-asserted/pinned LABEL — `publisher_claimed` — so a consumer that never read Y3 cannot over-attribute.
|
|
247
|
+
// (Pinning authenticates the KEY, not the name.)
|
|
248
|
+
const nameField = identity.strength === 'authoritative' ? { publisher: st.id.domain_shard } : { publisher_claimed: st.id.domain_shard };
|
|
249
|
+
return { result: 'VALID', identity, disclosed, sources, ...nameField,
|
|
250
|
+
ust_id: st.id.ust_id, class: st.id.class, content_hash: ch,
|
|
223
251
|
time: { strength: 'unproven', status: doc.proof ? 'present' : 'none' } };
|
|
224
252
|
} catch (e) {
|
|
225
253
|
return bad(e.code || 'E-MALFORMED', e.detail || String(e)); // fail-closed (§14/I10)
|
|
@@ -275,6 +303,12 @@ export function resolveAuthority(doc, { genesis, keylog = [], noForkConfirmed =
|
|
|
275
303
|
// ust:leaf/ust:node). The SUBSTRATE check (e.g. bitcoin-ots) is DELEGATED to opts.substrateVerify (needs
|
|
276
304
|
// external Bitcoin access — the caller/ustate's job). Returns { inclusion, time, status, anchorTime? }.
|
|
277
305
|
export function verifyAnchor(contentHash, proof, opts = {}) {
|
|
306
|
+
// fail-closed on a malformed proof: validate shape BEFORE recomputing (no TypeError, no dir!=L ⇒ R fallthrough).
|
|
307
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
308
|
+
if (!proof || typeof proof !== 'object' || !Array.isArray(proof.path) || !HASH.test(proof.root || ''))
|
|
309
|
+
return { inclusion: false, time: 'unproven', status: 'verified', error: 'E-ANCHOR', detail: 'malformed anchor proof' };
|
|
310
|
+
for (const s of proof.path) if (!s || (s.dir !== 'L' && s.dir !== 'R') || !HASH.test(s.hash || ''))
|
|
311
|
+
return { inclusion: false, time: 'unproven', status: 'verified', error: 'E-ANCHOR', detail: 'malformed path entry (dir must be "L"|"R", hash sha256:hex)' };
|
|
278
312
|
let node = Hbytes('ust:leaf', Buffer.from(contentHash, 'utf8'));
|
|
279
313
|
for (const s of proof.path) node = Hbytes('ust:node', Buffer.from(s.dir === 'L' ? s.hash + node : node + s.hash, 'utf8'));
|
|
280
314
|
const inclusion = node === proof.root;
|
|
@@ -292,9 +326,11 @@ export function verifyAnchor(contentHash, proof, opts = {}) {
|
|
|
292
326
|
export function verifyStream(frames, { genesis, checkpoint, requirePerFrameValid = true } = {}) {
|
|
293
327
|
if (!Array.isArray(frames) || !frames.length) return { complete: 'none' };
|
|
294
328
|
let prevHash = genesis ? contentHash(genesis) : null;
|
|
329
|
+
const authority = frames[0].state.id.domain_shard; // §11.3: a stream belongs to ONE authority
|
|
295
330
|
const seenUstId = new Set(), seenPrev = new Set();
|
|
296
331
|
for (const [i, f] of frames.entries()) {
|
|
297
332
|
if (requirePerFrameValid) { const v = verify(f, { context: 'data' }); if (v.result !== 'VALID') return { error: 'E-SIG', detail: 'frame ' + i + ' invalid: ' + v.error }; } // X2
|
|
333
|
+
if (f.state.id.domain_shard !== authority) return { error: 'E-AUTHORITY', detail: 'frame ' + i + ' domain_shard != stream authority (' + authority + ') — mixed-authority stream' };
|
|
298
334
|
if (seenUstId.has(f.state.id.ust_id)) return { error: 'E-PREV', detail: 'duplicate ust_id (fork, Y1): ' + f.state.id.ust_id };
|
|
299
335
|
seenUstId.add(f.state.id.ust_id);
|
|
300
336
|
const p = f.state.provenance?.prev;
|
|
@@ -308,6 +344,7 @@ export function verifyStream(frames, { genesis, checkpoint, requirePerFrameValid
|
|
|
308
344
|
if (checkpoint) {
|
|
309
345
|
const cv = verify(checkpoint, { context: 'data' });
|
|
310
346
|
if (cv.result !== 'VALID' || checkpoint.state.id.class !== 'attestation') return { error: 'E-PREV', detail: 'invalid checkpoint' };
|
|
347
|
+
if (checkpoint.state.id.domain_shard !== authority) return { error: 'E-AUTHORITY', detail: 'checkpoint not from the stream authority (' + authority + ') — TOP completeness cannot cross authority' };
|
|
311
348
|
const a = checkpoint.state.data.checkpoint?.value;
|
|
312
349
|
if (!a || a.head !== prevHash || String(a.frame_count) !== String(frames.length))
|
|
313
350
|
return { error: 'E-PREV', detail: 'checkpoint contradicts observed set (M5)' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ust-protocol",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.3",
|
|
4
4
|
"description": "Universal State Transcript (UST) — the stateless reference base: canonical hashing (JCS), Ed25519 signing, three-tier verification (LIGHT/HIGH/TOP), privacy commitments, chains, and anchoring. Trust infrastructure for machine-readable state.",
|
|
5
5
|
"author": "THE LAB (https://thelab.md)",
|
|
6
6
|
"type": "module",
|