ust-protocol 1.0.0-rc.1 → 1.0.0-rc.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/README.md +1 -1
- package/index.mjs +39 -12
- 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,7 +194,10 @@ 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)');
|
|
@@ -203,7 +221,7 @@ export function verify(doc, opts = {}) {
|
|
|
203
221
|
if (reproduced !== part.commit) return bad('E-COMMIT', 'blinded commit mismatch: ' + name);
|
|
204
222
|
if (part.privacy === 'encrypted' && part.enc && opts.decKeys?.[part.enc.key_id]) {
|
|
205
223
|
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,
|
|
224
|
+
if (pt === null || pt !== canon({ nonce: disc.nonce, partition: name, value: disc.value })) return bad('E-COMMIT', 'AEAD↔commit mismatch: ' + name);
|
|
207
225
|
}
|
|
208
226
|
disclosed.push(name);
|
|
209
227
|
}
|
|
@@ -275,6 +293,12 @@ export function resolveAuthority(doc, { genesis, keylog = [], noForkConfirmed =
|
|
|
275
293
|
// ust:leaf/ust:node). The SUBSTRATE check (e.g. bitcoin-ots) is DELEGATED to opts.substrateVerify (needs
|
|
276
294
|
// external Bitcoin access — the caller/ustate's job). Returns { inclusion, time, status, anchorTime? }.
|
|
277
295
|
export function verifyAnchor(contentHash, proof, opts = {}) {
|
|
296
|
+
// fail-closed on a malformed proof: validate shape BEFORE recomputing (no TypeError, no dir!=L ⇒ R fallthrough).
|
|
297
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
298
|
+
if (!proof || typeof proof !== 'object' || !Array.isArray(proof.path) || !HASH.test(proof.root || ''))
|
|
299
|
+
return { inclusion: false, time: 'unproven', status: 'verified', error: 'E-ANCHOR', detail: 'malformed anchor proof' };
|
|
300
|
+
for (const s of proof.path) if (!s || (s.dir !== 'L' && s.dir !== 'R') || !HASH.test(s.hash || ''))
|
|
301
|
+
return { inclusion: false, time: 'unproven', status: 'verified', error: 'E-ANCHOR', detail: 'malformed path entry (dir must be "L"|"R", hash sha256:hex)' };
|
|
278
302
|
let node = Hbytes('ust:leaf', Buffer.from(contentHash, 'utf8'));
|
|
279
303
|
for (const s of proof.path) node = Hbytes('ust:node', Buffer.from(s.dir === 'L' ? s.hash + node : node + s.hash, 'utf8'));
|
|
280
304
|
const inclusion = node === proof.root;
|
|
@@ -292,9 +316,11 @@ export function verifyAnchor(contentHash, proof, opts = {}) {
|
|
|
292
316
|
export function verifyStream(frames, { genesis, checkpoint, requirePerFrameValid = true } = {}) {
|
|
293
317
|
if (!Array.isArray(frames) || !frames.length) return { complete: 'none' };
|
|
294
318
|
let prevHash = genesis ? contentHash(genesis) : null;
|
|
319
|
+
const authority = frames[0].state.id.domain_shard; // §11.3: a stream belongs to ONE authority
|
|
295
320
|
const seenUstId = new Set(), seenPrev = new Set();
|
|
296
321
|
for (const [i, f] of frames.entries()) {
|
|
297
322
|
if (requirePerFrameValid) { const v = verify(f, { context: 'data' }); if (v.result !== 'VALID') return { error: 'E-SIG', detail: 'frame ' + i + ' invalid: ' + v.error }; } // X2
|
|
323
|
+
if (f.state.id.domain_shard !== authority) return { error: 'E-AUTHORITY', detail: 'frame ' + i + ' domain_shard != stream authority (' + authority + ') — mixed-authority stream' };
|
|
298
324
|
if (seenUstId.has(f.state.id.ust_id)) return { error: 'E-PREV', detail: 'duplicate ust_id (fork, Y1): ' + f.state.id.ust_id };
|
|
299
325
|
seenUstId.add(f.state.id.ust_id);
|
|
300
326
|
const p = f.state.provenance?.prev;
|
|
@@ -308,6 +334,7 @@ export function verifyStream(frames, { genesis, checkpoint, requirePerFrameValid
|
|
|
308
334
|
if (checkpoint) {
|
|
309
335
|
const cv = verify(checkpoint, { context: 'data' });
|
|
310
336
|
if (cv.result !== 'VALID' || checkpoint.state.id.class !== 'attestation') return { error: 'E-PREV', detail: 'invalid checkpoint' };
|
|
337
|
+
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
338
|
const a = checkpoint.state.data.checkpoint?.value;
|
|
312
339
|
if (!a || a.head !== prevHash || String(a.frame_count) !== String(frames.length))
|
|
313
340
|
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.2",
|
|
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",
|