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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.mjs +54 -17
  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,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 (HIGH, §12/§14.3): resolved ONLY if the caller supplies genesis+keylog.
188
- const identity = opts.genesis ? resolveAuthority(doc, opts) : { strength: 'self-asserted', status: 'verified' };
189
- if (identity.error) return bad(identity.error, identity.detail); // forked genesis / broken key-log
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, [name]: disc.value })) return bad('E-COMMIT', 'AEAD↔commit mismatch: ' + name);
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
- return { result: 'VALID', identity, disclosed, sources,
222
- publisher: st.id.domain_shard, ust_id: st.id.ust_id, class: st.id.class, content_hash: ch,
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.1",
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",