@cello-protocol/protocol-types 0.0.20 → 0.0.22

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 (45) hide show
  1. package/dist/cbor.d.ts +5 -0
  2. package/dist/cbor.d.ts.map +1 -0
  3. package/dist/cbor.js +31 -0
  4. package/dist/cbor.js.map +1 -0
  5. package/dist/connection-package.d.ts +4 -0
  6. package/dist/connection-package.d.ts.map +1 -1
  7. package/dist/connection-package.js +5 -6
  8. package/dist/connection-package.js.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/limits.d.ts +10 -0
  14. package/dist/limits.d.ts.map +1 -0
  15. package/dist/limits.js +10 -0
  16. package/dist/limits.js.map +1 -0
  17. package/dist/manifest.d.ts +2 -4
  18. package/dist/manifest.d.ts.map +1 -1
  19. package/dist/manifest.js +2 -4
  20. package/dist/manifest.js.map +1 -1
  21. package/dist/primary-transfer.d.ts +5 -7
  22. package/dist/primary-transfer.d.ts.map +1 -1
  23. package/dist/primary-transfer.js +7 -12
  24. package/dist/primary-transfer.js.map +1 -1
  25. package/dist/revocation.d.ts.map +1 -1
  26. package/dist/revocation.js +2 -5
  27. package/dist/revocation.js.map +1 -1
  28. package/dist/session-liveness.js +4 -4
  29. package/dist/session-liveness.js.map +1 -1
  30. package/dist/session.d.ts +26 -61
  31. package/dist/session.d.ts.map +1 -1
  32. package/dist/session.js +22 -54
  33. package/dist/session.js.map +1 -1
  34. package/dist/structure1.d.ts +16 -0
  35. package/dist/structure1.d.ts.map +1 -0
  36. package/dist/structure1.js +33 -0
  37. package/dist/structure1.js.map +1 -0
  38. package/dist/structure2.d.ts.map +1 -1
  39. package/dist/structure2.js +7 -8
  40. package/dist/structure2.js.map +1 -1
  41. package/package.json +2 -2
  42. package/dist/envelope.d.ts +0 -170
  43. package/dist/envelope.d.ts.map +0 -1
  44. package/dist/envelope.js +0 -776
  45. package/dist/envelope.js.map +0 -1
package/dist/envelope.js DELETED
@@ -1,776 +0,0 @@
1
- /**
2
- * @cello-protocol/protocol-types — CELLO-MSG-001 (v0) + CELLO-MSG-003 (v1)
3
- * Envelope construction, validation, serialization, and deserialization.
4
- *
5
- * ──── v0 Pseudocode (CELLO-MSG-001) ────
6
- *
7
- * buildEnvelope(content, keyProvider, timestamp):
8
- * 1. Reject if content.length > MAX_CONTENT_BYTES (1,048,576) → content_too_large
9
- * 2. Compute content_hash = msgLeafHash(content) [FIPS 180-4: SHA-256(0x00||content)]
10
- * 3. Fetch sender_pubkey = await keyProvider.getPublicKey()
11
- * 4. Build TBS positional array: [0, content_hash, sender_pubkey, timestamp]
12
- * — timestamp encoded as BigInt so cbor-x emits uint64, not float64
13
- * 5. tbs_bytes = encodeTBS([0, content_hash, sender_pubkey, BigInt(timestamp)])
14
- * [RFC 8949 §4.2.1: canonical CBOR, Encoder({tagUint8Array:false})]
15
- * 6. sender_signature = await keyProvider.sign(tbs_bytes) [RFC 8032: Ed25519]
16
- * 7. Return envelope with all six fields populated
17
- *
18
- * validateEnvelope(envelope):
19
- * 1. Check protocol_version === 0 → unsupported_version
20
- * 2. Check presence + exact byte lengths of all typed fields:
21
- * sender_pubkey: 32 bytes → missing_field / invalid_field
22
- * content_hash: 32 bytes → missing_field / invalid_field
23
- * sender_signature: 64 bytes → missing_field / invalid_field
24
- * 3. Check timestamp >= 0 → invalid_field
25
- * 4. Check content.length <= MAX_CONTENT_BYTES → content_too_large
26
- * 5. Recompute expected_hash = msgLeafHash(content)
27
- * Compare byte-by-byte to content_hash → content_hash_mismatch (BEFORE sig check)
28
- * 6. Rebuild TBS bytes (same as step 5 in build)
29
- * 7. verify(sender_pubkey, tbs_bytes, sender_signature) → invalid_field('sender_signature')
30
- * 8. Return ok: true
31
- *
32
- * ──── v1 Pseudocode (CELLO-MSG-003) ────
33
- *
34
- * buildEnvelopeV1(content, keyProvider, timestamp, session_id, last_seen_seq):
35
- * 1. Reject if content.length > MAX_CONTENT_BYTES → content_too_large
36
- * [no hash/sig computed before this check — AC-009]
37
- * 2. Compute content_hash = msgLeafHash(content) [FIPS 180-4: SHA-256(0x00||content)]
38
- * Caller-supplied hashes ignored — SI-001
39
- * 3. Fetch sender_pubkey = await keyProvider.getPublicKey()
40
- * 4. Build Structure 1 (TBS) positional 6-element array:
41
- * [1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]
42
- * — protocol_version=1 (CBOR uint)
43
- * — content_hash: 32-byte CBOR bstr (SHA-256(0x00||content))
44
- * — sender_pubkey: 32-byte CBOR bstr (Ed25519 public key, RFC 8032)
45
- * — session_id: 16-byte CBOR bstr
46
- * — last_seen_seq: CBOR uint (non-negative integer)
47
- * — timestamp: CBOR uint (Unix ms; BigInt when > 0xFFFFFFFF for minimal encoding
48
- * per RFC 8949 §4.2.1)
49
- * 5. tbs_bytes = CBOR_ENC.encode(tbs_array)
50
- * [RFC 8949 §4.2.1: canonical CBOR, Encoder({tagUint8Array:false})]
51
- * 6. sender_signature = await keyProvider.sign(tbs_bytes) [RFC 8032: Ed25519]
52
- * 7. Return MessageEnvelopeV1 with all eight fields populated
53
- *
54
- * validateEnvelopeV1(envelope):
55
- * 1. Check protocol_version === 1 → unsupported_version (AC-003, AC-004)
56
- * Hard-reject: no v0 fallback, no negotiation (M1 drops v0)
57
- * 2. Field presence + byte-length checks:
58
- * sender_pubkey: 32 bytes → missing_field / invalid_field
59
- * content_hash: 32 bytes → missing_field / invalid_field
60
- * session_id: 16 bytes → missing_field / invalid_field
61
- * sender_signature: 64 bytes → missing_field / invalid_field
62
- * 3. Check last_seen_seq >= 0, integer → invalid_field
63
- * 4. Check timestamp >= 0, integer → invalid_field
64
- * 5. Check content.length <= MAX_CONTENT_BYTES → content_too_large
65
- * 6. Recompute expected_hash = msgLeafHash(content)
66
- * Compare byte-by-byte to content_hash → content_hash_mismatch (BEFORE sig check)
67
- * 7. Rebuild Structure 1 TBS bytes (same as step 5 in build)
68
- * 8. verify(sender_pubkey, tbs_bytes, sender_signature) → invalid_field('sender_signature')
69
- * 9. Return ok: true
70
- *
71
- * extractStructure1(envelope: MessageEnvelopeV1) → Uint8Array:
72
- * 1. Build positional 6-element array from envelope fields
73
- * 2. Return CBOR_ENC.encode([1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp])
74
- * This is the exact bytes the relay uses to build Structure 2 (MERKLE-002)
75
- *
76
- * References:
77
- * RFC 8949 §4.2.1 — Core Deterministic Encoding Requirements
78
- * RFC 8032 — Edwards-Curve Digital Signature Algorithm (EdDSA)
79
- * FIPS 180-4 — SHA-256 (used by msgLeafHash for content_hash computation)
80
- */
81
- import { Encoder } from "cbor-x";
82
- import { decode as cborDecode } from "cbor-x";
83
- import { msgLeafHash, verify } from "@cello-protocol/crypto";
84
- /** Maximum allowed content size: 1 MiB (AC-009, AC-010, AC-011). */
85
- export const MAX_CONTENT_BYTES = 1_048_576;
86
- /**
87
- * Canonical CBOR encoder per RFC 8949 §4.2.1.
88
- * tagUint8Array: false — encode Uint8Array as CBOR byte strings (major type 2),
89
- * not as typed-array tags (tag 64). This is the standard wire representation.
90
- */
91
- const CBOR_ENC = new Encoder({ tagUint8Array: false });
92
- /**
93
- * Encode the TBS positional array as canonical CBOR.
94
- *
95
- * TBS layout: [protocol_version, content_hash, sender_pubkey, timestamp]
96
- * - protocol_version: CBOR uint (0)
97
- * - content_hash: CBOR byte string (32 bytes)
98
- * - sender_pubkey: CBOR byte string (32 bytes)
99
- * - timestamp: CBOR uint64 (BigInt to force integer encoding, not float64)
100
- *
101
- * RFC 8949 §4.2.1: integers use the shortest encoding; byte strings are verbatim.
102
- * Using BigInt(timestamp) ensures cbor-x emits 0x1b (8-byte uint64) not 0xfb (float64).
103
- */
104
- function encodeTBS(protocolVersion, contentHash, senderPubkey, timestamp) {
105
- // RFC 8949 §4.2.1: integers must use the shortest encoding.
106
- // cbor-x encodes JS numbers ≤ 0xFFFFFFFF as 4-byte uint (minimal) but numbers
107
- // above that threshold as float64 (0xfb…) — not canonical. BigInt always emits
108
- // 8-byte uint64 regardless of value — non-minimal for small values.
109
- // Solution: use BigInt only when the value exceeds uint32 range, ensuring
110
- // shortest encoding across all valid Unix millisecond timestamps.
111
- const tsEncoded = timestamp > 0xFFFFFFFF ? BigInt(timestamp) : timestamp;
112
- const tbs = [protocolVersion, contentHash, senderPubkey, tsEncoded];
113
- return CBOR_ENC.encode(tbs);
114
- }
115
- /**
116
- * Build a signed MessageEnvelope.
117
- *
118
- * SI-001: content_hash is ALWAYS recomputed; any caller-supplied value is ignored.
119
- * AC-010: content > 1 MiB → content_too_large, no hash or signature computed.
120
- *
121
- * @param content - Raw message bytes
122
- * @param keyProvider - Signing key abstraction (K_local)
123
- * @param timestamp - Unix milliseconds (non-negative)
124
- */
125
- export async function buildEnvelope(content, keyProvider, timestamp) {
126
- // AC-010: reject oversized content before any crypto
127
- if (content.length > MAX_CONTENT_BYTES) {
128
- return {
129
- ok: false,
130
- error: {
131
- reason: "content_too_large",
132
- message: `content length ${content.length} exceeds maximum ${MAX_CONTENT_BYTES} bytes`,
133
- },
134
- };
135
- }
136
- // SI-001: always recompute — never trust caller-supplied hash
137
- const content_hash = msgLeafHash(content);
138
- const sender_pubkey = await keyProvider.getPublicKey();
139
- // TBS: positional array [protocol_version, content_hash, sender_pubkey, timestamp]
140
- const tbsBytes = encodeTBS(0, content_hash, sender_pubkey, timestamp);
141
- // Ed25519 sign per RFC 8032
142
- const sender_signature = await keyProvider.sign(tbsBytes);
143
- return {
144
- ok: true,
145
- envelope: {
146
- protocol_version: 0,
147
- sender_pubkey,
148
- content,
149
- content_hash,
150
- timestamp,
151
- sender_signature,
152
- },
153
- };
154
- }
155
- /**
156
- * Validate a MessageEnvelope.
157
- *
158
- * Validation order (fail-fast):
159
- * 1. protocol_version check (AC-013, SI-004) — before any other check
160
- * 2. Field presence and byte-length checks (AC-003, AC-004)
161
- * 3. timestamp range check (AC-005)
162
- * 4. content size check (AC-011)
163
- * 5. content_hash recomputation and comparison (AC-012) — BEFORE signature check
164
- * 6. Signature verification (AC-002, AC-007, SI-003)
165
- */
166
- export function validateEnvelope(envelope) {
167
- // Step 1: version check — first so unknown versions are detectable without parsing (SI-004)
168
- if (envelope.protocol_version !== 0) {
169
- return {
170
- ok: false,
171
- error: {
172
- reason: "unsupported_version",
173
- message: `unsupported protocol_version: ${envelope.protocol_version}`,
174
- },
175
- };
176
- }
177
- // Step 2: field presence and size checks
178
- if (envelope.sender_pubkey === undefined ||
179
- envelope.sender_pubkey === null) {
180
- return {
181
- ok: false,
182
- error: { reason: "missing_field", field: "sender_pubkey", message: "sender_pubkey is missing" },
183
- };
184
- }
185
- if (envelope.sender_pubkey.length !== 32) {
186
- return {
187
- ok: false,
188
- error: {
189
- reason: "invalid_field",
190
- field: "sender_pubkey",
191
- message: `sender_pubkey must be 32 bytes, got ${envelope.sender_pubkey.length}`,
192
- },
193
- };
194
- }
195
- if (envelope.content_hash === undefined ||
196
- envelope.content_hash === null) {
197
- return {
198
- ok: false,
199
- error: { reason: "missing_field", field: "content_hash", message: "content_hash is missing" },
200
- };
201
- }
202
- if (envelope.content_hash.length !== 32) {
203
- return {
204
- ok: false,
205
- error: {
206
- reason: "invalid_field",
207
- field: "content_hash",
208
- message: `content_hash must be 32 bytes, got ${envelope.content_hash.length}`,
209
- },
210
- };
211
- }
212
- if (envelope.sender_signature === undefined ||
213
- envelope.sender_signature === null) {
214
- return {
215
- ok: false,
216
- error: { reason: "missing_field", field: "sender_signature", message: "sender_signature is missing" },
217
- };
218
- }
219
- if (envelope.sender_signature.length !== 64) {
220
- return {
221
- ok: false,
222
- error: {
223
- reason: "invalid_field",
224
- field: "sender_signature",
225
- message: `sender_signature must be 64 bytes, got ${envelope.sender_signature.length}`,
226
- },
227
- };
228
- }
229
- // Step 3: timestamp range
230
- if (envelope.timestamp < 0 || !Number.isInteger(envelope.timestamp)) {
231
- return {
232
- ok: false,
233
- error: {
234
- reason: "invalid_field",
235
- field: "timestamp",
236
- message: `timestamp must be a non-negative integer, got ${envelope.timestamp}`,
237
- },
238
- };
239
- }
240
- // Step 4: content size
241
- if (envelope.content.length > MAX_CONTENT_BYTES) {
242
- return {
243
- ok: false,
244
- error: {
245
- reason: "content_too_large",
246
- message: `content length ${envelope.content.length} exceeds maximum ${MAX_CONTENT_BYTES} bytes`,
247
- },
248
- };
249
- }
250
- // Step 5: content_hash recomputation — checked BEFORE signature (AC-012)
251
- const expectedHash = msgLeafHash(envelope.content);
252
- if (!bytesEqual(expectedHash, envelope.content_hash)) {
253
- return {
254
- ok: false,
255
- error: {
256
- reason: "content_hash_mismatch",
257
- message: "content_hash does not match SHA-256(0x00 || content)",
258
- },
259
- };
260
- }
261
- // Step 6: signature verification
262
- const tbsBytes = encodeTBS(envelope.protocol_version, envelope.content_hash, envelope.sender_pubkey, envelope.timestamp);
263
- if (!verify(envelope.sender_pubkey, tbsBytes, envelope.sender_signature)) {
264
- return {
265
- ok: false,
266
- error: {
267
- reason: "invalid_field",
268
- field: "sender_signature",
269
- message: "signature verification failed",
270
- },
271
- };
272
- }
273
- return { ok: true };
274
- }
275
- /**
276
- * Serialize a MessageEnvelope to canonical CBOR bytes (RFC 8949 §4.2.1).
277
- *
278
- * The envelope is encoded as a CBOR map with string keys.
279
- * timestamp uses minimal encoding per RFC 8949 §4.2.1 — see encodeTBS comment.
280
- */
281
- export function serializeEnvelope(envelope) {
282
- const map = {
283
- protocol_version: envelope.protocol_version,
284
- sender_pubkey: envelope.sender_pubkey,
285
- content: envelope.content,
286
- content_hash: envelope.content_hash,
287
- timestamp: envelope.timestamp > 0xFFFFFFFF ? BigInt(envelope.timestamp) : envelope.timestamp,
288
- sender_signature: envelope.sender_signature,
289
- };
290
- return CBOR_ENC.encode(map);
291
- }
292
- /**
293
- * Deserialize a MessageEnvelope from CBOR bytes.
294
- *
295
- * Performs structural validation only (field presence, types, sizes).
296
- * Does NOT re-validate the signature or content_hash — call validateEnvelope for that.
297
- */
298
- export function deserializeEnvelope(bytes) {
299
- let raw;
300
- try {
301
- raw = cborDecode(bytes);
302
- }
303
- catch (e) {
304
- return {
305
- ok: false,
306
- error: {
307
- reason: "invalid_field",
308
- field: "bytes",
309
- message: `CBOR decode failed: ${e.message}`,
310
- },
311
- };
312
- }
313
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
314
- return {
315
- ok: false,
316
- error: {
317
- reason: "invalid_field",
318
- field: "bytes",
319
- message: "decoded value is not a map",
320
- },
321
- };
322
- }
323
- const obj = raw;
324
- // protocol_version
325
- if (!("protocol_version" in obj)) {
326
- return { ok: false, error: { reason: "missing_field", field: "protocol_version", message: "protocol_version is missing" } };
327
- }
328
- const pv = obj["protocol_version"];
329
- if (typeof pv !== "number" || pv !== 0) {
330
- return { ok: false, error: { reason: "unsupported_version", message: `unsupported protocol_version: ${pv}` } };
331
- }
332
- // sender_pubkey
333
- if (!("sender_pubkey" in obj)) {
334
- return { ok: false, error: { reason: "missing_field", field: "sender_pubkey", message: "sender_pubkey is missing" } };
335
- }
336
- const spk = toUint8Array(obj["sender_pubkey"]);
337
- if (!spk || spk.length !== 32) {
338
- return { ok: false, error: { reason: "invalid_field", field: "sender_pubkey", message: `sender_pubkey must be 32 bytes` } };
339
- }
340
- // content
341
- if (!("content" in obj)) {
342
- return { ok: false, error: { reason: "missing_field", field: "content", message: "content is missing" } };
343
- }
344
- const content = toUint8Array(obj["content"]);
345
- if (!content) {
346
- return { ok: false, error: { reason: "invalid_field", field: "content", message: "content must be bytes" } };
347
- }
348
- // content_hash
349
- if (!("content_hash" in obj)) {
350
- return { ok: false, error: { reason: "missing_field", field: "content_hash", message: "content_hash is missing" } };
351
- }
352
- const ch = toUint8Array(obj["content_hash"]);
353
- if (!ch || ch.length !== 32) {
354
- return { ok: false, error: { reason: "invalid_field", field: "content_hash", message: `content_hash must be 32 bytes` } };
355
- }
356
- // timestamp
357
- if (!("timestamp" in obj)) {
358
- return { ok: false, error: { reason: "missing_field", field: "timestamp", message: "timestamp is missing" } };
359
- }
360
- const tsRaw = obj["timestamp"];
361
- const ts = typeof tsRaw === "bigint" ? Number(tsRaw) : (typeof tsRaw === "number" ? tsRaw : NaN);
362
- if (!Number.isInteger(ts) || ts < 0 || ts > Number.MAX_SAFE_INTEGER) {
363
- return { ok: false, error: { reason: "invalid_field", field: "timestamp", message: `invalid timestamp: ${tsRaw}` } };
364
- }
365
- // sender_signature
366
- if (!("sender_signature" in obj)) {
367
- return { ok: false, error: { reason: "missing_field", field: "sender_signature", message: "sender_signature is missing" } };
368
- }
369
- const sig = toUint8Array(obj["sender_signature"]);
370
- if (!sig || sig.length !== 64) {
371
- return { ok: false, error: { reason: "invalid_field", field: "sender_signature", message: `sender_signature must be 64 bytes` } };
372
- }
373
- return {
374
- ok: true,
375
- envelope: {
376
- protocol_version: 0,
377
- sender_pubkey: spk,
378
- content,
379
- content_hash: ch,
380
- timestamp: ts,
381
- sender_signature: sig,
382
- },
383
- };
384
- }
385
- // ─── v1 functions (CELLO-MSG-003) ────────────────────────────────────────────
386
- /**
387
- * Encode the v1 Structure 1 (TBS) positional 6-element array as canonical CBOR.
388
- *
389
- * Structure 1 layout: [protocol_version, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]
390
- * - protocol_version: CBOR uint, always 1
391
- * - content_hash: CBOR byte string (32 bytes, SHA-256(0x00||content))
392
- * - sender_pubkey: CBOR byte string (32 bytes, Ed25519 public key per RFC 8032)
393
- * - session_id: CBOR byte string (16 bytes)
394
- * - last_seen_seq: CBOR uint (non-negative integer)
395
- * - timestamp: CBOR uint (Unix ms; BigInt for values > 0xFFFFFFFF per RFC 8949 §4.2.1 minimal encoding)
396
- *
397
- * RFC 8949 §4.2.1: integers must use shortest encoding. BigInt forces 8-byte uint64 for
398
- * timestamps above uint32 range (all valid Unix ms timestamps since ~Feb 2106).
399
- */
400
- function encodeTBSV1(contentHash, senderPubkey, sessionId, lastSeenSeq, timestamp) {
401
- const tsEncoded = timestamp > 0xFFFFFFFF ? BigInt(timestamp) : timestamp;
402
- const tbs = [1, contentHash, senderPubkey, sessionId, lastSeenSeq, tsEncoded];
403
- return CBOR_ENC.encode(tbs);
404
- }
405
- /**
406
- * Build a signed v1 MessageEnvelopeV1 (CELLO-MSG-003).
407
- *
408
- * SI-001: content_hash is ALWAYS recomputed from content; any caller-supplied value is ignored.
409
- * AC-009: content > 1 MiB → content_too_large, no hash or signature computed.
410
- *
411
- * @param content - Raw message bytes (up to 1 MiB)
412
- * @param keyProvider - Signing key abstraction (K_local)
413
- * @param timestamp - Unix milliseconds (non-negative)
414
- * @param session_id - 16-byte session identifier
415
- * @param last_seen_seq - Highest canonical seq number seen from relay (0 for first message)
416
- */
417
- export async function buildEnvelopeV1(content, keyProvider, timestamp, session_id, last_seen_seq) {
418
- // session_id must be exactly 16 bytes — catch at build time, not just validation time
419
- if (session_id.length !== 16) {
420
- return {
421
- ok: false,
422
- error: {
423
- reason: "invalid_field",
424
- field: "session_id",
425
- message: `session_id must be 16 bytes, got ${session_id.length}`,
426
- },
427
- };
428
- }
429
- // AC-009: reject oversized content before any crypto
430
- if (content.length > MAX_CONTENT_BYTES) {
431
- return {
432
- ok: false,
433
- error: {
434
- reason: "content_too_large",
435
- message: `content length ${content.length} exceeds maximum ${MAX_CONTENT_BYTES} bytes`,
436
- },
437
- };
438
- }
439
- // SI-001: always recompute — never trust caller-supplied hash
440
- const content_hash = msgLeafHash(content);
441
- const sender_pubkey = await keyProvider.getPublicKey();
442
- // Structure 1 (TBS): [1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp]
443
- const tbsBytes = encodeTBSV1(content_hash, sender_pubkey, session_id, last_seen_seq, timestamp);
444
- // Ed25519 sign per RFC 8032
445
- const sender_signature = await keyProvider.sign(tbsBytes);
446
- return {
447
- ok: true,
448
- envelope: {
449
- protocol_version: 1,
450
- sender_pubkey,
451
- content,
452
- content_hash,
453
- session_id,
454
- last_seen_seq,
455
- timestamp,
456
- sender_signature,
457
- },
458
- };
459
- }
460
- /**
461
- * Validate a v1 MessageEnvelopeV1 (CELLO-MSG-003).
462
- *
463
- * Validation order (fail-fast):
464
- * 1. protocol_version === 1 check — hard-reject v0 and any other version (AC-003, AC-004)
465
- * 2. Field presence and byte-length checks
466
- * 3. last_seen_seq and timestamp range checks
467
- * 4. content size check
468
- * 5. content_hash recomputation (BEFORE signature check) (AC-006)
469
- * 6. Signature verification over Structure 1 TBS
470
- */
471
- export function validateEnvelopeV1(envelope) {
472
- // Step 1: version gate — hard-reject any version != 1 (M1 drops v0; no negotiation)
473
- if (envelope.protocol_version !== 1) {
474
- return {
475
- ok: false,
476
- error: {
477
- reason: "unsupported_version",
478
- message: `unsupported protocol_version: ${envelope.protocol_version}`,
479
- },
480
- };
481
- }
482
- // Step 2a: sender_pubkey — 32 bytes
483
- if (envelope.sender_pubkey === undefined || envelope.sender_pubkey === null) {
484
- return {
485
- ok: false,
486
- error: { reason: "missing_field", field: "sender_pubkey", message: "sender_pubkey is missing" },
487
- };
488
- }
489
- if (envelope.sender_pubkey.length !== 32) {
490
- return {
491
- ok: false,
492
- error: {
493
- reason: "invalid_field",
494
- field: "sender_pubkey",
495
- message: `sender_pubkey must be 32 bytes, got ${envelope.sender_pubkey.length}`,
496
- },
497
- };
498
- }
499
- // Step 2b: content_hash — 32 bytes
500
- if (envelope.content_hash === undefined || envelope.content_hash === null) {
501
- return {
502
- ok: false,
503
- error: { reason: "missing_field", field: "content_hash", message: "content_hash is missing" },
504
- };
505
- }
506
- if (envelope.content_hash.length !== 32) {
507
- return {
508
- ok: false,
509
- error: {
510
- reason: "invalid_field",
511
- field: "content_hash",
512
- message: `content_hash must be 32 bytes, got ${envelope.content_hash.length}`,
513
- },
514
- };
515
- }
516
- // Step 2c: session_id — 16 bytes
517
- if (envelope.session_id === undefined || envelope.session_id === null) {
518
- return {
519
- ok: false,
520
- error: { reason: "missing_field", field: "session_id", message: "session_id is missing" },
521
- };
522
- }
523
- if (envelope.session_id.length !== 16) {
524
- return {
525
- ok: false,
526
- error: {
527
- reason: "invalid_field",
528
- field: "session_id",
529
- message: `session_id must be 16 bytes, got ${envelope.session_id.length}`,
530
- },
531
- };
532
- }
533
- // Step 2d: sender_signature — 64 bytes
534
- if (envelope.sender_signature === undefined || envelope.sender_signature === null) {
535
- return {
536
- ok: false,
537
- error: { reason: "missing_field", field: "sender_signature", message: "sender_signature is missing" },
538
- };
539
- }
540
- if (envelope.sender_signature.length !== 64) {
541
- return {
542
- ok: false,
543
- error: {
544
- reason: "invalid_field",
545
- field: "sender_signature",
546
- message: `sender_signature must be 64 bytes, got ${envelope.sender_signature.length}`,
547
- },
548
- };
549
- }
550
- // Step 3: last_seen_seq range
551
- if (!Number.isInteger(envelope.last_seen_seq) || envelope.last_seen_seq < 0) {
552
- return {
553
- ok: false,
554
- error: {
555
- reason: "invalid_field",
556
- field: "last_seen_seq",
557
- message: `last_seen_seq must be a non-negative integer, got ${envelope.last_seen_seq}`,
558
- },
559
- };
560
- }
561
- // Step 4: timestamp range
562
- if (!Number.isInteger(envelope.timestamp) || envelope.timestamp < 0) {
563
- return {
564
- ok: false,
565
- error: {
566
- reason: "invalid_field",
567
- field: "timestamp",
568
- message: `timestamp must be a non-negative integer, got ${envelope.timestamp}`,
569
- },
570
- };
571
- }
572
- // Step 5: content size
573
- if (envelope.content.length > MAX_CONTENT_BYTES) {
574
- return {
575
- ok: false,
576
- error: {
577
- reason: "content_too_large",
578
- message: `content length ${envelope.content.length} exceeds maximum ${MAX_CONTENT_BYTES} bytes`,
579
- },
580
- };
581
- }
582
- // Step 6: content_hash recomputation — checked BEFORE signature (AC-006)
583
- const expectedHash = msgLeafHash(envelope.content);
584
- if (!bytesEqual(expectedHash, envelope.content_hash)) {
585
- return {
586
- ok: false,
587
- error: {
588
- reason: "content_hash_mismatch",
589
- message: "content_hash does not match SHA-256(0x00 || content)",
590
- },
591
- };
592
- }
593
- // Step 7: signature verification over Structure 1 TBS
594
- const tbsBytes = encodeTBSV1(envelope.content_hash, envelope.sender_pubkey, envelope.session_id, envelope.last_seen_seq, envelope.timestamp);
595
- if (!verify(envelope.sender_pubkey, tbsBytes, envelope.sender_signature)) {
596
- return {
597
- ok: false,
598
- error: {
599
- reason: "invalid_field",
600
- field: "sender_signature",
601
- message: "signature verification failed",
602
- },
603
- };
604
- }
605
- return { ok: true };
606
- }
607
- /**
608
- * Serialize a v1 MessageEnvelopeV1 to canonical CBOR bytes (RFC 8949 §4.2.1).
609
- *
610
- * The envelope is encoded as a CBOR map with string keys.
611
- * timestamp uses minimal encoding per RFC 8949 §4.2.1.
612
- */
613
- export function serializeEnvelopeV1(envelope) {
614
- const map = {
615
- protocol_version: envelope.protocol_version,
616
- sender_pubkey: envelope.sender_pubkey,
617
- content: envelope.content,
618
- content_hash: envelope.content_hash,
619
- session_id: envelope.session_id,
620
- last_seen_seq: envelope.last_seen_seq,
621
- timestamp: envelope.timestamp > 0xFFFFFFFF ? BigInt(envelope.timestamp) : envelope.timestamp,
622
- sender_signature: envelope.sender_signature,
623
- };
624
- return CBOR_ENC.encode(map);
625
- }
626
- /**
627
- * Deserialize a v1 MessageEnvelopeV1 from CBOR bytes.
628
- *
629
- * Performs structural validation only (field presence, types, sizes).
630
- * Does NOT re-validate the signature or content_hash — call validateEnvelopeV1 for that.
631
- */
632
- export function deserializeEnvelopeV1(bytes) {
633
- let raw;
634
- try {
635
- raw = cborDecode(bytes);
636
- }
637
- catch (e) {
638
- return {
639
- ok: false,
640
- error: {
641
- reason: "invalid_field",
642
- field: "bytes",
643
- message: `CBOR decode failed: ${e.message}`,
644
- },
645
- };
646
- }
647
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
648
- return {
649
- ok: false,
650
- error: {
651
- reason: "invalid_field",
652
- field: "bytes",
653
- message: "decoded value is not a map",
654
- },
655
- };
656
- }
657
- const obj = raw;
658
- // protocol_version — must be exactly 1
659
- if (!("protocol_version" in obj)) {
660
- return { ok: false, error: { reason: "missing_field", field: "protocol_version", message: "protocol_version is missing" } };
661
- }
662
- const pv = obj["protocol_version"];
663
- if (typeof pv !== "number" || pv !== 1) {
664
- return { ok: false, error: { reason: "unsupported_version", message: `unsupported protocol_version: ${pv}` } };
665
- }
666
- // sender_pubkey — 32 bytes
667
- if (!("sender_pubkey" in obj)) {
668
- return { ok: false, error: { reason: "missing_field", field: "sender_pubkey", message: "sender_pubkey is missing" } };
669
- }
670
- const spk = toUint8Array(obj["sender_pubkey"]);
671
- if (!spk || spk.length !== 32) {
672
- return { ok: false, error: { reason: "invalid_field", field: "sender_pubkey", message: "sender_pubkey must be 32 bytes" } };
673
- }
674
- // content
675
- if (!("content" in obj)) {
676
- return { ok: false, error: { reason: "missing_field", field: "content", message: "content is missing" } };
677
- }
678
- const content = toUint8Array(obj["content"]);
679
- if (!content) {
680
- return { ok: false, error: { reason: "invalid_field", field: "content", message: "content must be bytes" } };
681
- }
682
- // content_hash — 32 bytes
683
- if (!("content_hash" in obj)) {
684
- return { ok: false, error: { reason: "missing_field", field: "content_hash", message: "content_hash is missing" } };
685
- }
686
- const ch = toUint8Array(obj["content_hash"]);
687
- if (!ch || ch.length !== 32) {
688
- return { ok: false, error: { reason: "invalid_field", field: "content_hash", message: "content_hash must be 32 bytes" } };
689
- }
690
- // session_id — 16 bytes
691
- if (!("session_id" in obj)) {
692
- return { ok: false, error: { reason: "missing_field", field: "session_id", message: "session_id is missing" } };
693
- }
694
- const sid = toUint8Array(obj["session_id"]);
695
- if (!sid || sid.length !== 16) {
696
- return { ok: false, error: { reason: "invalid_field", field: "session_id", message: "session_id must be 16 bytes" } };
697
- }
698
- // last_seen_seq — non-negative integer
699
- if (!("last_seen_seq" in obj)) {
700
- return { ok: false, error: { reason: "missing_field", field: "last_seen_seq", message: "last_seen_seq is missing" } };
701
- }
702
- const lssRaw = obj["last_seen_seq"];
703
- const lss = typeof lssRaw === "bigint" ? Number(lssRaw) : (typeof lssRaw === "number" ? lssRaw : NaN);
704
- if (!Number.isInteger(lss) || lss < 0 || lss > Number.MAX_SAFE_INTEGER) {
705
- return { ok: false, error: { reason: "invalid_field", field: "last_seen_seq", message: `invalid last_seen_seq: ${lssRaw}` } };
706
- }
707
- // timestamp — non-negative integer
708
- if (!("timestamp" in obj)) {
709
- return { ok: false, error: { reason: "missing_field", field: "timestamp", message: "timestamp is missing" } };
710
- }
711
- const tsRaw = obj["timestamp"];
712
- const ts = typeof tsRaw === "bigint" ? Number(tsRaw) : (typeof tsRaw === "number" ? tsRaw : NaN);
713
- if (!Number.isInteger(ts) || ts < 0 || ts > Number.MAX_SAFE_INTEGER) {
714
- return { ok: false, error: { reason: "invalid_field", field: "timestamp", message: `invalid timestamp: ${tsRaw}` } };
715
- }
716
- // sender_signature — 64 bytes
717
- if (!("sender_signature" in obj)) {
718
- return { ok: false, error: { reason: "missing_field", field: "sender_signature", message: "sender_signature is missing" } };
719
- }
720
- const sig = toUint8Array(obj["sender_signature"]);
721
- if (!sig || sig.length !== 64) {
722
- return { ok: false, error: { reason: "invalid_field", field: "sender_signature", message: "sender_signature must be 64 bytes" } };
723
- }
724
- return {
725
- ok: true,
726
- envelope: {
727
- protocol_version: 1,
728
- sender_pubkey: spk,
729
- content,
730
- content_hash: ch,
731
- session_id: sid,
732
- last_seen_seq: lss,
733
- timestamp: ts,
734
- sender_signature: sig,
735
- },
736
- };
737
- }
738
- /**
739
- * Extract Structure 1 from a v1 envelope — returns the canonical CBOR bytes that were signed.
740
- *
741
- * Structure 1: canonical_CBOR([1, content_hash, sender_pubkey, session_id, last_seen_seq, timestamp])
742
- *
743
- * This is the exact bytes the relay uses to build Structure 2 (CELLO-MERKLE-002).
744
- * Per RFC 8949 §4.2.1 canonical CBOR and RFC 8032 Ed25519.
745
- */
746
- export function extractStructure1(envelope) {
747
- return encodeTBSV1(envelope.content_hash, envelope.sender_pubkey, envelope.session_id, envelope.last_seen_seq, envelope.timestamp);
748
- }
749
- // ─── Internal helpers ────────────────────────────────────────────────────────
750
- /**
751
- * Constant-time byte comparison.
752
- * Note: for HMAC verification, use a real constant-time compare.
753
- * For hash comparison this is sufficient since timing leaks only reveal
754
- * content we already possess.
755
- */
756
- function bytesEqual(a, b) {
757
- if (a.length !== b.length)
758
- return false;
759
- let diff = 0;
760
- for (let i = 0; i < a.length; i++) {
761
- diff |= a[i] ^ b[i];
762
- }
763
- return diff === 0;
764
- }
765
- /**
766
- * Coerce a CBOR-decoded value to Uint8Array.
767
- * cbor-x with tagUint8Array:false returns Buffer (a Node.js Buffer is a Uint8Array subclass).
768
- */
769
- function toUint8Array(v) {
770
- if (v instanceof Uint8Array)
771
- return v;
772
- if (Buffer.isBuffer(v))
773
- return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
774
- return null;
775
- }
776
- //# sourceMappingURL=envelope.js.map