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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.mjs +39 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,7 +17,7 @@ adapter for browsers and Workers — same rules, same results).
17
17
  ## Install
18
18
 
19
19
  ```
20
- npm i ust-protocol
20
+ npm i ust-protocol@rc
21
21
  ```
22
22
 
23
23
  ## Verify a document
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. captured binds domain_shard; computed omits it (cross-engine); private = over commit
32
- export function partitionHash({ domain_shard, ust_id, name, value, kind, commit }) {
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
- const scope = kind === 'computed' ? { ust_id, [name]: value } : { domain_shard, ust_id, [name]: value };
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, [name]: value }));
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, kind: part.kind });
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
- const TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; // §6 pinned RFC3339 UTC-Z
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 names must be non-reserved-envelope, non-collide
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, kind: part.kind });
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, [name]: disc.value })) return bad('E-COMMIT', 'AEAD↔commit mismatch: ' + name);
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.1",
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",