dig-message 0.5.1

The DIG Network generic base message protocol: the byte-deterministic Chia-Streamable envelope + InnerMessage, length-framed size-bounded framing, additive compression (raw + zstd, decompression-bomb guarded), and the extensible message-type registry that every directed (1:1 / group) e2e-sealed peer message rides.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
# dig-message — SPECIFICATION

Normative specification of the DIG Network generic base message protocol: the ONE structured, typed,
streamable, e2e-sealed envelope every DIRECTED (1:1 / group) peer-to-peer message rides. The
authoritative contract an independent reimplementation is built against. Key words per RFC 2119.

Status: skeleton (design frozen by DIG-Network/dig_ecosystem#796 / #811, amended 2026-07-19 to a
COMPRESSED BINARY format — compress-before-seal, additive compression-algo negotiation, decompression-
bomb guard — to a UNIVERSALLY SIGNED + REPLAY-PROTECTED format — mandatory CHIA BLS (BLS12-381)
sender-signature inside the seal on EVERY message/frame + a bounded sliding-window anti-replay scheme — and to an
DHKEM-over-BLS12-381-G1 AUTH-MODE seal (HPKE-style, ONE Chia BLS keypair does BOTH the G2 sender
signature AND the G1 ECDH seal — NO X25519, NO Ed25519) openable ONLY by the recipient private key +
the sender public key; self-addressed (sender==recipient key, e.g. IPC) is first-class; byte-level KAT
vectors are filled during WU1-WU5 implementation, marked [KAT: ...] below). The design decisions herein
are final.

## 0. Scope, stack position, and the two-wires reconciliation (NORMATIVE)

dig-message is the base message protocol layered between the transport and the identity layer:

    dig-gossip (transport: mTLS P2P, discovery, relay/NAT, opaque byte delivery, peer_id=SHA256(SPKI DER))
      -> dig-message (THIS crate: envelope + framing + type registry + streaming + e2e seal)
        -> dig-identity (DID: ONE Chia BLS12-381 identity key 0x0010 = sign (G2) + seal-DH (G1); peer_id 0x0012)
          -> dig-chat / dig-email / dig-video-chat / peer-RPC / data-request  (message TYPES)

The DIG Network has TWO distinct wires; this reconciliation is normative:

1. The client-to-node JSON-RPC-over-HTTP ladder — dig-rpc-protocol (types) + dig-rpc (axum server),
   ecosystem section 5.3 (dig.local -> localhost -> rpc.dig.net). Client-to-node control/read; browsers
   reach the PublicRead surface over plain HTTPS with NO client cert and NO recipient DID.
   dig-message SITS BESIDE this wire and does NOT subsume it. Rationale (final, no user fork):
   (a) the section 5.3 golden RPC fixtures MUST NOT break; (b) ANONYMOUS public/browser reads have no
   recipient DID to seal to (section 5.4 needs the recipient's BLS identity key) and require plain
   HTTPS+CORS a browser can present. The anonymous-HTTP-read path is the ONLY path that stays BESIDE
   dig-message (unsealed). NOTE: authenticated local IPC (dig-app <-> dig-node, a SHARED/same identity
   key) is NOT beside — it RIDES dig-message as a registered type (see wire 2 + section 4 IPC band),
   sealed+signed to the shared key (the self-addressed case, section 5.6a). The HTTP JSON-RPC ladder is
   unchanged.

2. The peer-to-peer directed-message wire — dig-protocol DigMessage (Chia Message framing, u8 msg_type)
   carried over dig-gossip. dig-message SUBSUMES the directed peer-to-peer framing layer: every directed
   message a peer sends another (chat, email, video signaling, presence, directed data requests,
   peer-to-peer request/response, AND authenticated local IPC — dig-ipc-protocol is a dig-message TYPE,
   section 4) is a dig-message envelope. Consensus BROADCAST (opcodes 200-219:
   blocks, transactions, attestations, checkpoints — public, all-peers) is the documented section 5.4
   exemption and stays mTLS-authenticated + signed, NOT dig-message-sealed.

Transport binding. A dig-message envelope is the data region of a dig_protocol::DigMessage with
msg_type = DIG_MESSAGE (220) — a single new directed opcode in the free 220..=255 band (200..=219 are
consensus broadcast). It rides the dig-gossip directed seam (send_to / request / a new streaming
helper). dig-gossip already exposes directed unicast (GossipHandle::send_to, ::request) beside
broadcast; the streaming helper is added in WU6.

## 1. Encoding, framing & compression (NORMATIVE)

- Compact BINARY format. dig-message is a compact binary wire format, NOT a text/JSON format. The
  envelope and every sub-structure are Chia Streamable (canonical, byte-deterministic, length-prefixed
  for variable regions) so Rust, wasm, and JS agree byte-for-byte (reuse chia-protocol /
  chia-wallet-sdk Streamable; never hand-roll framing, never emit JSON/text on the wire).
- Payload pipeline (NORMATIVE ordering, security-critical): serialize the type payload (Streamable
  bytes) -> COMPRESS (section 1.1) -> e2e-SEAL (DHKEM-over-G1 auth mode, section 5). Compression MUST
  happen BEFORE the seal: sealed ciphertext is high-entropy and incompressible, so compressing after the
  seal is useless;
  compressing the plaintext first is the only placement that gains size. The receiver reverses it:
  open seal -> bomb-guard check -> decompress -> decode payload.
- Length-framed + size-bounded. The outer DigMessage is already length-framed by dig-protocol;
  dig-message re-declares a hard MAX_ENVELOPE_BYTES cap (default 16 MiB, measured on the on-wire
  compressed+sealed frame) and a MAX_DECOMPRESSED_BYTES cap (default 64 MiB per message; stream chunks
  capped separately, section 3). A receiver MUST reject an over-cap or truncated envelope before
  decoding, and MUST enforce MAX_DECOMPRESSED_BYTES BEFORE and DURING decompression (section 1.1
  decompression-bomb guard).
- Canonical types. DID identifiers are Bytes32 (identity singleton launcher_id) via
  chia-protocol/chia-wallet-sdk. Keys: ONE Chia BLS12-381 identity key (dig-identity slot 0x0010) —
  a 48-byte compressed G1 public key (minimal-pubkey-size), 96-byte G2 signature — is used for BOTH the
  sender signature (G2) AND the seal DH (G1). NO X25519, NO Ed25519 (section 5.1).

### 1.1 Compression layer (additive negotiation + bomb guard, NORMATIVE)

Compression is applied to the Streamable payload bytes INSIDE the seal (the compressed bytes are the
sealed plaintext; the algorithm id and original length are carried in the sealed InnerMessage,
section 5.2 — so they are AEAD-authenticated and NOT relay-visible, adding no metadata leak).

- Algorithm id (u8, additive per section 5.1). Once assigned, an id is NEVER renumbered or repurposed.

  | id | Algorithm | Support | Use |
  |---|---|---|---|
  | 0 | none / raw | MANDATORY (every impl MUST support) | Small or incompressible payloads (section below). |
  | 1 | zstd | RECOMMENDED default | General payloads above the raw threshold. Level PINNED = 3 (zstd default; deterministic, no dictionary). |
  | 2..=63 | reserved (standard algos) | — | Future standard codecs (e.g. brotli), assigned additively. |
  | 64..=255 | reserved (experimental/vendor) | — | Never shipped as canonical. |

- Raw threshold. A sender MUST use id=0 (raw) when the payload is below MIN_COMPRESS_BYTES
  (default 64 bytes) OR when compression does not shrink it (compressed length >= raw length —
  incompressible/already-compressed data). Otherwise it SHOULD use id=1 (zstd). This keeps small and
  incompressible payloads from paying a compression header/expansion penalty.
- Unknown-id handling (never crash). A receiver that does not recognize the compression id MUST fail
  cleanly: UNSUPPORTED_COMPRESSION for request/stream, silent drop for one-shot (mirrors the
  section 4 unknown-type rule). It MUST NOT panic or attempt to decompress with a wrong codec.
- Decompression-bomb guard (HARD). The sealed InnerMessage carries uncompressed_len:u32 (the declared
  original length). Before decompressing, a receiver MUST reject the frame if uncompressed_len >
  MAX_DECOMPRESSED_BYTES. During decompression it MUST bound the output stream to MAX_DECOMPRESSED_BYTES
  and abort (reject the frame) if the decoder tries to exceed that OR if the actual decoded length !=
  uncompressed_len. A hostile peer MUST NOT be able to OOM the host via a compression bomb.
- Codec = a byte-deterministic zstd usable from Rust AND wasm/JS (section 1.2). id=0 is the trivial
  identity codec (payload bytes verbatim).

### 1.2 wasm/JS codec parity (NORMATIVE)

The zstd codec (id=1) MUST have a wasm/JS-compatible implementation so browser/extension peers encode
and decode byte-identically to native Rust peers. Rust uses the `zstd` crate (libzstd bindings) pinned
to level 3, no dictionary, single-frame; the wasm/JS target uses a WASM build of libzstd
(e.g. `@bokuweb/zstd-wasm` / a vendored `libzstd` compiled to wasm) at the SAME level/params. Because
compression happens before the seal and the compressed bytes are what get AEAD-authenticated, the two
targets MUST produce identical compressed bytes for the pinned params — a Rust<->wasm/JS byte-agreement
KAT (section 7, WU5) proves it. id=0 (raw) is trivially identical across targets.

## 2. Base envelope (byte-level, NORMATIVE)

DigMessageEnvelope (Streamable), field order normative:

| # | Field | Type | Meaning |
|---|---|---|---|
| 1 | version | u8 | Envelope format version (current = 1). Additive: a newer reader MUST accept older versions; an unknown newer version is rejected UNSUPPORTED_VERSION. |
| 2 | message_type | u32 | Extensible type-registry id (section 4). Additive-only, never renumbered/repurposed (section 5.1 spirit). |
| 3 | flags | u8 | Bitfield: bits 0-1 = interaction shape (0=one-shot, 1=request, 2=response, 3=stream-frame); bit 2 = sealed (MUST be 1 for directed messages); remaining reserved-zero. |
| 4 | correlation_id | Bytes32 | Random per initiating message; echoed by the response and by every frame of a stream. |
| 5 | sender | Bytes32 | Sender DID launcher_id. |
| 6 | recipient | Bytes32 | Recipient DID launcher_id. |
| 7 | sender_epoch | u32 | Sender key epoch (identity slot 0x0013), for key-rotation disambiguation. |
| 8 | stream | Option<StreamHeader> | Present iff flags shape = stream-frame (section 3). |
| 9 | sealed | SealedPayload | The e2e-sealed region (section 5). ALL type-specific content lives here. |

The header fields (1-8) are cleartext envelope metadata (a relay needs recipient to route and
correlation_id/stream to multiplex). message_type is ALSO cleartext (routing) AND re-bound inside the
seal (section 5) to prevent type-confusion. No payload content is ever cleartext. The compression
algorithm id and the uncompressed length live INSIDE the seal (section 5.2 InnerMessage), NOT in the
cleartext header — so compression metadata is AEAD-authenticated and invisible to the relay.
[KAT: golden envelope bytes for each shape, including a raw(id=0) and a zstd(id=1) sealed payload.]
(WU1 golden vectors — committed as SHA-256 digests of the deterministic on-wire bytes — live in
`tests/kat.rs`: one-shot / request / response / stream-frame envelope encodings + the raw and
zstd(level-3) compression round-trips; the seal/signature/replay/streaming vectors are the marked
WU2/WU4 placeholders there.)

## 3. The three interaction shapes + streaming state machine (NORMATIVE)

- One-shot (fire-and-forget): a single envelope, shape=one-shot, no response expected.
- Request/response (correlated): a request envelope (shape=request) and a response envelope
  (shape=response) sharing correlation_id. A responder MUST echo the correlation_id; a requester MUST
  match responses by it and enforce a timeout.
- Streaming (first-class base capability): a long-lived, ordered, backpressured, cancelable,
  bidirectionally half-closable channel keyed by correlation_id.

StreamHeader (Streamable): { frame: u8, seq: u64, window: u32 } where frame in
{OPEN=0, OPEN_ACK=1, DATA=2, CREDIT=3, CLOSE=4, CLOSE_ACK=5, RESET=6}.

State machine (each direction tracked independently for half-close):

    Idle --OPEN(seal establishes stream secret, section 5.3)--> Opening
    Opening --OPEN_ACK--> Open
    Open  --DATA(seq, <=MAX_CHUNK)--> Open   (ordered: seq strictly monotonic; gap/reorder = error)
    Open  <--CREDIT(n)-->                    (credit-based backpressure: <= granted window in flight)
    Open  --CLOSE--> HalfClosed(local)       (this direction done; peer may still send)
          (both directions CLOSE + CLOSE_ACK) --> Closed
    Open/Opening/HalfClosed --RESET--> Closed (immediate abort, either party, any state)

- Ordered: seq is strictly monotonic per direction from 0; a receiver MUST reject a gap or
  out-of-order/replayed seq. The reliable mTLS WS transport guarantees delivery order; seq detects
  tampering/loss and drives the AEAD nonce chain (section 5.3).
- Backpressure: credit-based flow control. The receiver grants a window via CREDIT(n); the sender MUST
  NOT have more than the granted window of unacknowledged DATA in flight. Initial window is in
  OPEN/OPEN_ACK.
- Cancelable: RESET aborts immediately from any state, either party.
- Half-close both ways: CLOSE closes the sending direction only; the peer keeps sending until it also
  sends CLOSE. Full close when both directions closed (or on RESET).
- Chunk cap: MAX_CHUNK_BYTES (default 1 MiB).
- Signed frames: EVERY stream frame is BLS-signed (section 5.1) inside its seal with the frame type
  + seq bound; an unsigned/bad-sig/replayed/reordered frame is rejected (section 5.3/5.6).
- [KAT: streaming round-trip; a CANCEL/RESET vector; a backpressure vector; a cross-session frame-replay
  reject vector.]

## 4. Extensible type registry (NORMATIVE)

- MessageType(u32) newtype. Additive-only: an id, once assigned, is never renumbered or repurposed; a
  receiver that does not recognize a message_type MUST fail cleanly (UNSUPPORTED_TYPE for request/stream;
  silently drop for one-shot) — never panic (section 5.1 spirit).
- Reserved bands (normative allocation; each subsystem owns a band, additive within it):

  | Band | Owner |
  |---|---|
  | 0x0000_0000 .. 0x0000_00FF | core (handshake, ack, error, keepalive) |
  | 0x0000_0100 .. 0x0000_01FF | peer-RPC (peer-to-peer request/response) |
  | 0x0000_0600 .. 0x0000_06FF | dig-ipc-protocol (authenticated local dig-app <-> dig-node IPC; self-addressed shared key) |
  | 0x0000_0200 .. 0x0000_02FF | dig-chat (#768) |
  | 0x0000_0300 .. 0x0000_03FF | dig-email (#794) |
  | 0x0000_0400 .. 0x0000_04FF | dig-video-chat (#795, signaling) |
  | 0x0000_0500 .. 0x0000_05FF | presence / directed data-request |
  | 0x0000_0700 .. 0x0000_07FF | dig-social-graph connection manager (#1192, #991) |
  | 0x0000_0800 .. 0x0000_08FF | node<->relay sealed control (register/hole-punch/retainer, #1199) |
  | 0x0000_0900 .. 0x0000_09FF | relay<->relay mesh (#1200) |
  | >= 0x1000_0000 | experimental / vendor (never shipped as canonical) |

  Core band assignments (additive, WU3): handshake = 0x0000_0000, ack = 0x0000_0001,
  error = 0x0000_0002, keepalive = 0x0000_0003. The social-graph, relay-control, and relay-mesh bands
  above are RESERVATIONS ONLY (Tier-0 foundation for the relay seal family, #1199/#1200, and the
  social-graph connection manager, #1192): no concrete MessageType ids are allocated within them yet
  beyond the first social-graph consumer id noted in dig-social-graph's own SPEC (#991 SG-2); a reader
  MUST still classify any id in these ranges to its named band. An id that falls in NO allocated
  subsystem band above (e.g. 0x0000_0A00 .. 0x0FFF_FFFF) is Reserved for a future band; a reader MUST
  still be able to classify it (it maps to the Reserved band) and MUST apply the unknown-type rule if
  it is unregistered.
- The registration seam (Rust): a MessageKind trait — const TYPE_ID: MessageType; type Payload:
  Streamable; — plus a runtime MessageRegistry (MessageType -> decode+dispatch) a subsystem populates
  additively. A downstream crate (dig-chat) implements MessageKind for its payload types and registers
  them; dig-message never depends on a downstream crate. Registering an already-assigned id is REFUSED
  (never silently overwritten), upholding the additive-only rule. dispatch(message_type, shape, payload)
  routes to the registered handler; an unregistered type returns UNSUPPORTED_TYPE for a request/stream
  shape and is silently dropped for a one-shot/response shape (never a panic).
- [KAT: unknown-type-dropped (one-shot) + unknown-type-error (request) vectors.]

## 5. Security — e2e seal + universal sender signature + replay protection (HARD, ecosystem section 5.4)

mTLS is assumed at the dig-gossip transport. The dig-message sealed region is ADDITIONALLY sealed to the
recipient identity key so any TLS-terminating relay/forwarder sees only ciphertext. ONE key type does
everything: the identity Chia BLS12-381 keypair (dig-identity slot 0x0010) — its G2 signatures
authenticate the sender, and ECDH over its G1 group seals the payload. There is NO X25519 and NO Ed25519
anywhere.

### 5.1 Keys, signature, and seal (vetted; NEVER invented)

- Identity key = Chia BLS12-381 (minimal-pubkey-size: G1 public key = 48-byte compressed point, private
  = a scalar in Z_r, G2 signature = 96-byte compressed point), the wallet-controlled key via
  chia-wallet-sdk / chia_bls (blst). The SAME keypair is used for BOTH the sender signature (G2) and the
  seal DH (G1). Section 5.7 covers the key-reuse safety + domain separation.
- Sender SIGNATURE = BLS (AugSchemeMPL, the Chia augmented scheme), MANDATORY and UNIVERSAL: EVERY
  dig-message — one-shot, request, response, and every streaming frame (OPEN / OPEN_ACK / DATA / CREDIT
  / CLOSE / CLOSE_ACK / RESET) — carries a sender signature. No unsigned or bad-signature message/frame
  is EVER accepted (fail-closed, section 5.6). The signature is carried INSIDE the seal (not
  relay-visible; only the recipient learns + verifies the sender). It signs SIG_DOMAIN || transcript
  (domain separation, section 5.1a), where transcript binds EVERYTHING (nothing malleable):
  version || message_type || flags || correlation_id || sender || recipient || sender_epoch || counter
  || timestamp_ms || expires_at || stream_frame || stream_seq || kem_enc || compression ||
  uncompressed_len || compressed_payload_hash. stream_frame/stream_seq are the section 3 stream fields
  (0 for non-stream); compressed_payload_hash is the SHA-256 of the on-the-wire compressed payload bytes;
  kem_enc is the seal KEM encapsulation (below). Binding kem_enc prevents KEM-reuse/replay across
  recipients; binding counter + timestamp_ms is the anti-replay commitment (section 5.6); binding
  expires_at makes the TTL sender-authenticated + un-extendable (section 5.6b); binding stream_frame +
  stream_seq prevents per-frame replay/reorder/cross-frame splice (section 5.3).
  - Transcript encoding (NORMATIVE, byte-deterministic — the cross-implementation contract). The signed
    bytes are `SIG_DOMAIN || transcript`, `SIG_DOMAIN = "DIGNET-MSG:dig-message/v1"` (ASCII, no NUL).
    `transcript` is the fixed-width, BIG-ENDIAN concatenation, in this exact order:
    `version:u8 ‖ message_type:u32 ‖ flags:u8 ‖ correlation_id:32B ‖ sender:32B ‖ recipient:32B ‖
    sender_epoch:u32 ‖ counter:u64 ‖ timestamp_ms:u64 ‖ expires_at:u64 ‖ stream_frame:u8 ‖
    stream_seq:u64 ‖ kem_enc:48B ‖ compression:u8 ‖ uncompressed_len:u32 ‖ compressed_payload_hash:32B`
    — total 224 transcript bytes plus the domain tag. `stream_frame`/`stream_seq` are 0 for a
    non-stream message; `compressed_payload_hash` is the SHA-256 of the on-wire compressed payload
    bytes. A second implementation MUST reproduce these bytes exactly for its signature to verify.
  - Seal composition (NORMATIVE). The DHKEM-over-G1 key schedule is HKDF-SHA256 with `salt = empty`,
    `ikm = Z` (the two concatenated 48-byte DH points, section 5.1 order), and
    `info = "dig-message/dhkem-g1/v1" || kem_enc || sender_pub || recipient_pub`, expanded to 44 bytes
    = the 32-byte ChaCha20Poly1305 key ‖ the 12-byte base nonce. `ciphertext = AEAD.Seal(key, nonce,
    aad = cleartext-header-bytes, pt = InnerMessage)` (section 5.2).
- SEAL = DHKEM over the BLS12-381 G1 prime-order subgroup + HKDF-SHA256 + ChaCha20Poly1305-AEAD, in AUTH
  mode. This instantiates the GENERIC, group-agnostic HPKE DHKEM (RFC 9180 section 4.1) over a valid
  prime-order DH group (G1, order r), exactly as ECIES/DHIES (SEC1, ISO 18033-2) is defined over any such
  group — a sound, established construction, NOT an invented primitive. The DH is scalar-mult on G1:
  dh(sk, pk) = sk * pk (a G1 point), serialized as the 48-byte compressed point.
  - AUTH mode = ephemeral + static-sender (the HPKE AuthEncap analog, chosen for FORWARD SECRECY — a
    long-lived stream or a later static-key compromise does not retroactively expose past traffic; the
    static-sender term still gives the "openable only by recipient priv + sender pub" property):
    - Sender: generate ephemeral (esk, epk=esk*G1); kem_enc = epk (48 bytes). Compute
      Z = dh(esk, recipient_pub) || dh(sender_static_sk, recipient_pub). shared_secret =
      HKDF-SHA256(salt=empty, ikm=Z, info="dig-message/dhkem-g1/v1" || kem_enc || sender_pub ||
      recipient_pub) -> the AEAD key + base nonce.
    - Recipient: Z2 = dh(recipient_sk, kem_enc) || dh(recipient_sk, sender_static_pub); same HKDF -> the
      same key. Opening therefore REQUIRES the recipient private key AND the correct sender public key; a
      wrong/absent sender pubkey yields a different key and AEAD-open FAILS.
  - Subgroup safety (HARD): before ANY DH, a receiver MUST validate every received G1 point (kem_enc and
    the resolved sender_pub) is a valid, non-identity point in the prime-order r-subgroup (blst in_g1 /
    subgroup check; reject the identity/infinity point and any small-order point). This blocks
    small-subgroup / invalid-curve key-recovery attacks. A point failing the check -> REJECT.

### 5.1a BLS signature domain separation vs chain spends (HARD — custody-adjacent)
The identity BLS key is Chia-native; a dig-message signature MUST be impossible to confuse with, or
replay as, a Chia spend signature (AGG_SIG_ME / AGG_SIG_UNSAFE) or vice-versa. Two layers, both REQUIRED:

1. Message domain tag. The signed message is SIG_DOMAIN || transcript, where
   SIG_DOMAIN = "DIGNET-MSG:dig-message/v1" (a fixed ASCII augmentation). An AGG_SIG_ME message is the
   condition message with coin_id || genesis_challenge appended and never carries SIG_DOMAIN, so a
   dig-message signature can never be a valid AGG_SIG_ME signature, and an AGG_SIG_ME signature (bound to
   a coin + genesis) never verifies against the SIG_DOMAIN-prefixed dig-message message.
2. Key separation (the LOAD-BEARING defense against AGG_SIG_UNSAFE). Because AGG_SIG_UNSAFE signs an
   attacker-chosen message with NO chain-appended suffix, a message domain tag ALONE is insufficient (an
   attacker can craft an AGG_SIG_UNSAFE condition whose message equals our signed bytes). The identity
   signing key (0x0010) therefore MUST NOT be a wallet coin-custody / spend key: it secures NO coins, so
   any confused-deputy signature authorizes nothing of value. Implementations MUST sign dig-messages ONLY
   via a dedicated dig-message helper — NEVER through any wallet spend-signing code path, and NEVER with a
   key that guards funds.

### 5.2 SealedPayload (Streamable)
{ kem_enc: [u8;48], ciphertext: Vec<u8> } where kem_enc is the ephemeral G1 encapsulation (section 5.1)
and ciphertext = AEAD.Seal(key=shared_secret, nonce=base_nonce, aad = cleartext-header-bytes,
pt = InnerMessage). Binding the cleartext header as AAD prevents an on-path party from altering routing
metadata. To resolve sender_pub the recipient reads the sender DID from the cleartext header (bound as
AAD, so un-tamperable) and resolves that DID BLS G1 identity key (slot 0x0010) via dig-identity at the
sender_epoch epoch, runs the subgroup check (section 5.1), then decapsulates + opens; an
unknown/unresolvable sender DID, a non-subgroup point, or a mismatched sender key -> open FAILS
(fail-closed).

InnerMessage (Streamable), FINAL field list = { message_type: u32, correlation_id: Bytes32,
compression: u8, uncompressed_len: u32, counter: u64, timestamp_ms: u64, expires_at: u64,
payload: Vec<u8>, sender_sig: [u8;96] } where payload is the COMPRESSED type-payload bytes (section 1.1),
compression is the algorithm id, uncompressed_len is the declared original length (the bomb-guard bound),
counter + timestamp_ms are the anti-replay fields (section 5.6), expires_at is the sender-controlled TTL
(section 5.6b), and sender_sig is the MANDATORY 96-byte BLS G2 signature (section 5.1) — every
InnerMessage carries it, none is optional. A receiver MUST, in order: (a) verify sender_sig (AugSchemeMPL)
against the resolved sender G1 key over SIG_DOMAIN || transcript — an absent, malformed, or non-verifying
signature is a REJECT (fail-closed); (b) check inner message_type/correlation_id equal the cleartext
header (anti type-confusion / anti splice); (c) apply the expiry check (section 5.6b) and DISCARD if
expired; (d) run the anti-replay check (section 5.6) on (sender, sender_epoch, counter, timestamp_ms) and
REJECT a replay/stale message; (e) reject if uncompressed_len > MAX_DECOMPRESSED_BYTES; then
(f) decompress payload per compression under the section 1.1 output bound and check the decoded length ==
uncompressed_len. Because every field lives inside the AEAD-authenticated + signed InnerMessage, none can
be tampered by a relay.

### 5.3 Streaming keys
EVERY stream frame (OPEN / OPEN_ACK / DATA / CREDIT / CLOSE / CLOSE_ACK / RESET) is an INDEPENDENT
section 5.1 sealed envelope: BLS-signed inside its own seal with the transcript binding stream_frame +
stream_seq, and G1-DHKEM auth-sealed under its OWN FRESH ephemeral (a distinct kem_enc per frame). This
per-frame fresh-ephemeral rule is NORMATIVE and load-bearing (WU4 security review, #1162/#1183): reusing
one ECDH ephemeral across frames would reuse the derived (key, base-nonce) and a repeated
ChaCha20Poly1305 nonce is a catastrophic AEAD break, so a conforming sender MUST NOT derive DATA-chunk
keys from a single shared per-stream secret with nonce = seq. Each frame therefore gets per-frame forward
secrecy for free, and no frame can be replayed, reordered, injected, or forged. Cross-session frame
replay is rejected by TWO independent mechanisms: (a) the persistent per-(sender → recipient) anti-replay
scheme of section 5.6 (monotonic counter + freshness window + sliding-window dedup) drops any captured
frame re-injected later; and (b) a frame's correlation_id must address a LIVE session at the receiver — a
frame from another stream/session carries a different (random) correlation_id and matches no session. The
monotonic per-direction stream_seq (section 3) is the in-order delivery + replay index within a session.

Each DATA chunk is compressed (section 1.1) INDEPENDENTLY before its seal — one compression context per
chunk, never a shared running context (see the compress-then-encrypt boundary, section 5.5). The stream
compression algorithm id is fixed at OPEN (carried in the sealed OPEN InnerMessage); each DATA chunk
declares its own uncompressed_len for the per-chunk MAX_CHUNK_DECOMPRESSED_BYTES bomb guard
(default = MAX_CHUNK_BYTES). The stream expires_at is the OPEN frame expires_at and bounds the WHOLE
session: once now_ms > session.expires_at a receiver MUST DROP further frames for that stream (no RESET —
consistent with the anti-storm rule below; section 5.6b). A per-peer concurrent-stream cap (MAX_CONCURRENT_STREAMS, default 64) bounds how many
streams one peer may hold OPEN at once — the transport reassembler is bounded per-stream (256 chunks /
4 MiB), so without this cap N concurrent streams would allow an N × 4 MiB memory DoS; a new OPEN beyond
the cap is rejected and the stream RESET. The base spec provides this per-stream secure channel; a higher
layer (dig-chat #768) MAY layer a Double Ratchet over its payload.

RESET-on-failed-verify — DROP, do not broadcast (NORMATIVE, anti-storm). The security purpose of
rejecting a bad frame is to NEVER deliver corrupt data and NEVER let a bad frame poison a stream;
SILENTLY DROPPING the frame fully satisfies that. A RESET is itself a real signed, non-replayable frame,
so a receiver MUST NOT emit one in response to unauthenticated or duplicate input — otherwise the
untrusted relay (section 5.4) weaponizes it: (a) a self-sustaining RESET reflection storm (inject ONE
garbage frame with an arbitrary correlation_id → receiver RESETs → the other side has no such session →
it RESETs back → unbounded ping-pong, each hop a BLS verify + BLS sign + KEM), and (b) a replay-teardown
(re-inject one prior valid DATA frame on a LIVE stream → the anti-replay guard rejects it → a RESET tears
down the healthy stream — a relay holding NO key kills any stream). Therefore a receiver MUST **DROP
silently** (no frame emitted, any live session left UNTOUCHED) for: a failed open/verify (bad seal, bad
signature, replay, expiry, unresolvable sender), a non-stream or unknown-kind frame, an inbound RESET,
and any frame addressing an UNKNOWN stream. A receiver MUST emit a RESET **only** for a state-machine
violation (bad seq/credit/half-close) detected on an AUTHENTICATED frame for a KNOWN, live session, or
for the concurrent-stream cap on an authenticated OPEN — cases where the frame is provably genuine, so it
cannot be forged or replayed and the peer receiving the RESET for its own live/opening stream tears it
down without re-RESETting. **A RESET MUST NEVER beget a RESET.**

**Sender-binding hard rule (NORMATIVE):** a stream endpoint is constructed for exactly ONE peer DID
(`peer_did`). `open_message`'s `resolve_sender_pub` callback proves only that `envelope.sender` genuinely
holds whatever key the callback returns for it — it says nothing about WHICH peer this session belongs
to. The implementation MUST additionally assert `opened.sender == peer_did` after a successful open and
hard-DROP (never RESET) on mismatch, and callers MUST supply a `resolve_sender_pub` that is itself
peer-scoped (resolves keys only for the DID this endpoint session was constructed for). This is
defense-in-depth: a peer-scoped resolver alone already prevents cross-peer frame injection, but the
in-code assertion holds even if a resolver is accidentally wired permissively (looks up ANY DID).

### 5.4 Public-broadcast exemption
Consensus broadcast (opcodes 200-219: blocks, transactions, attestations, checkpoints — addressed to ALL
peers) has no single recipient key and is NOT dig-message-sealed; it stays mTLS-authenticated + signed.
dig-message governs DIRECTED (1:1/group) messaging only.

- [KAT: seal/open round-trip with fixed keys (test nonces DERIVED from a hashed seed, never integer
  literals — CodeQL); a relay-sees-only-ciphertext test (no plaintext substring on the wire); a
  tampered-AAD / tampered-sig rejection; a wrong-recipient decrypt-fail; a wrong-sender-pubkey
  decrypt-fail; a NON-SUBGROUP / identity-point kem_enc -> REJECT; a COMPRESSED (id=1)
  compress->seal->open->decompress round-trip == original; a raw (id=0) round-trip; an
  unknown-compression-id-rejected; a decompression-bomb-rejected; the G1-DH round-trip agrees
  sender-side == recipient-side.]

### 5.5 Compress-then-encrypt threat boundary (CRIME/BREACH class, NORMATIVE)

Compressing before encrypting can, in the general TLS/HTTP setting, leak plaintext via ciphertext length
under an adaptive-chosen-plaintext attacker (CRIME/BREACH): the attacker repeatedly injects data into a
compression context that ALSO contains a stable secret, and reads the compressed length to guess the
secret byte-by-byte. That attack class does NOT apply to dig-message discrete sealed messages, and the
SPEC forbids the configurations where it would:

- Each message (and each stream chunk) is compressed in its OWN, fresh compression context, then sealed
  with a FRESH ephemeral KEM (or a fresh per-chunk AEAD nonce over a per-stream key). There is no
  long-lived compression context mixing a stable secret with attacker-varied inputs across many probes,
  and the length side channel is per-discrete-message, not a repeated oracle over one secret.
- FORBIDDEN (MUST NOT): compressing attacker-influenced data together WITH secret data in a single
  compression context, or streaming a stable secret repeatedly alongside attacker-chosen data in one
  running compression context. Every compression context MUST be single-message / single-chunk
  (section 5.3) — the boundary that keeps compress-before-seal provably safe here.
- Payloads that genuinely interleave a per-connection secret with attacker-chosen content in one buffer
  MUST set compression=0 (raw); they are outside the safe regime above.

### 5.6 Replay protection (mandatory, ALL messages — NORMATIVE)

Every non-broadcast dig-message is replay-protected. The seal gives confidentiality + AEAD integrity, the
BLS signature gives non-repudiable sender-auth, and this section adds FRESHNESS — three distinct,
all-required properties. The scheme (pinned):

- Anti-replay fields (signed + sealed, section 5.2): counter: u64 — a per-(sender -> recipient)
  strictly-monotonic message counter the SENDER persists per recipient (starts at 0, +1 each message,
  never reused/decreased); timestamp_ms: u64 — sender wall-clock Unix milliseconds. Both are inside the
  seal and covered by the signature, so neither is malleable by a relay.
- Freshness window: FRESHNESS_WINDOW_MS (default 300_000 = +/-5 min). A receiver REJECTS a message whose
  timestamp_ms is outside [now - FRESHNESS_WINDOW_MS, now + FRESHNESS_WINDOW_MS].
- Sliding-window dedup (bounded, DoS-safe). Per (sender DID, sender_epoch) the receiver keeps O(1) state:
  a highest_counter: u64 plus a fixed-width bitmap window REPLAY_WINDOW (default 1024 bits). counter >
  highest -> ACCEPT + advance + set bit; within window & bit UNSET -> ACCEPT + set bit (in-window
  reorder); bit already SET or counter <= highest - REPLAY_WINDOW -> REJECT. The bitmap is fixed-size, so
  a counter flood from one sender CANNOT grow per-sender state.
- Memory bound (nonce-flood / Sybil guard). The tracked-sender set is a bounded LRU capped at
  MAX_TRACKED_SENDERS (default 100_000), evicting senders idle beyond FRESHNESS_WINDOW_MS first; a new
  sender entry requires a valid BLS signature over a resolvable DID (section 5.1), so forging distinct
  senders to exhaust the LRU is cryptographically costly.
- Streaming: within a session the per-direction monotonic stream_seq (section 3) is the replay index; the
  per-stream key is bound to the fresh OPEN kem_enc (section 5.3) so no frame is replayable across
  sessions. The OPEN frame itself is covered by the counter/timestamp scheme.
- Fail-closed: a message failing signature OR the anti-replay check is DROPPED, never delivered.

### 5.6a Self-addressed messages (sender == recipient key; IPC — NORMATIVE, HARD)
The sender and recipient identity key MAY be the SAME (self-addressed: local IPC dig-app <-> dig-node on
a shared identity, a note-to-self, a loopback test). This is FIRST-CLASS and MUST round-trip identically
to a distinct-party message. No layer may assume sender != recipient:
- Seal: the G1 auth-DH is well-defined when sender_static == recipient. The static term is
  dh(recipient_sk, sender_static_pub) = sk*(sk*G1) = (sk^2)*G1 — a valid, non-identity G1 point for any
  sk != 0 (a real BLS key is never 0), so there is no divide-by-zero / degenerate / identity result and
  no self-rejection. The ephemeral term dh(esk, recipient_pub) is independent and always valid. Seal and
  open therefore succeed to self.
- Signature: the BLS sig signs + verifies a self-addressed transcript normally (sender==recipient in the
  transcript is just data).
- Replay: the per-(sender -> recipient) counter + dedup key is the ORDERED PAIR (sender, recipient); the
  pair (X, X) is a valid, independent counter stream — no assumption the two DIDs differ.
- Envelope: sender == recipient is a VALID envelope; a receiver MUST NOT reject it.
- [KAT: a self-addressed (sender key == recipient key) message round-trips — seal->open succeeds, BLS sig
  verifies, replay accepts-then-rejects-on-replay — byte-for-byte identical semantics to a distinct-party
  message.]

### 5.6b Message expiry / TTL (sender-controlled discard — NORMATIVE)
expires_at: u64 (Unix milliseconds) is a SENDER-controlled per-message time-to-live, inside the seal and
covered by the signature transcript (section 5.1) — so a relay/attacker can neither extend nor shorten it
without breaking the signature. It is DISTINCT from and INDEPENDENT of the anti-replay freshness window
(section 5.6): freshness is a fixed +/-5 min anti-replay bound on timestamp_ms; expires_at is a
per-message validity deadline the sender chooses (an offer valid 1h, a presence ping valid 30s).

- Semantics: a receiver DISCARDS (rejects with NO side-effect — not delivered, not counted, no error
  reply) any message where now_ms > expires_at.
- Sentinel: expires_at == 0 means NO explicit expiry (the section 5.6 freshness window still bounds a
  non-streaming message replay validity).
- Cap: a non-zero expires_at MUST NOT exceed timestamp_ms + MAX_MESSAGE_TTL_MS (MAX_MESSAGE_TTL_MS
  default 2_592_000_000 = 30 days); a message claiming a longer validity is REJECTED (never clamped —
  clamping would alter signed content). This stops a message claiming near-infinite validity.
- Both checks apply INDEPENDENTLY: an expired-but-in-freshness-window message is still DISCARDED (expiry
  wins), and a fresh-but-expired message is DISCARDED; a message must pass BOTH expiry AND anti-replay to
  be delivered.
- Streaming: the OPEN frame expires_at bounds the whole session; once now_ms > session.expires_at the
  receiver DROPS further frames for that stream (no RESET — an expired frame is unauthenticated-for-
  this-purpose input, exactly the anti-storm DROP case of section 5.3, never a RESET-worthy violation).
- [KAT: within-expires accepted; past-expires DISCARDED (no processing/side-effect); a tampered
  expires_at -> sig-verify FAILS (confirms it is inside the seal + signed); expired-but-in-freshness-
  window -> DISCARDED; fresh-but-expired -> DISCARDED; expires_at > timestamp_ms + MAX_MESSAGE_TTL_MS ->
  REJECTED; expires_at == 0 -> no expiry applied.]

### 5.7 Composed, distinct guarantees + key-reuse safety (NORMATIVE)

A directed dig-message composes independent security properties on ONE BLS keypair; all required:

1. Seal — DHKEM-over-G1 AUTH mode (section 5.1): CONFIDENTIALITY + FORWARD SECRECY (ephemeral) + DENIABLE
   sender-auth bound to BOTH keys (opens only with recipient priv AND correct sender pub). The AEAD is
   symmetric/deniable, so it authenticates the sender TO THE RECIPIENT but is not transferable.
2. BLS (G2) signature (section 5.1/5.2): TRANSFERABLE, Chia-native NON-REPUDIATION — a third party can
   verify the sender signed. Kept alongside the deniable seal precisely because the seal alone is
   deniable.
3. Anti-replay (section 5.6) + expiry (section 5.6b): FRESHNESS + sender-controlled validity.

Key-reuse safety (same BLS scalar for G2-signing AND G1-DH). The two uses live in DIFFERENT groups (sign
hashes to G2 and outputs sk*H_G2(m); DH outputs sk*P for a G1 point P) and are further separated by
distinct KDF/augment domains — SIG_DOMAIN="DIGNET-MSG:dig-message/v1" for signatures vs
info="dig-message/dhkem-g1/v1"||... for the KEM HKDF — so neither use is an oracle for the other: a
signature never reveals sk*P for an attacker-chosen G1 P, and a DH never produces a G2 signature. This
mirrors established single-key sign+DH reuse arguments under domain separation and is documented as an
accepted residual (a derived encryption sub-scalar would be a defense-in-depth ALTERNATIVE, but the
ecosystem mandates ONE keypair; the group + domain separation is the mitigation). See the threat model.

Ordering: sender computes the BLS sig over SIG_DOMAIN||transcript (which binds the replay token + expiry +
header + compressed payload), places it in InnerMessage, then G1-auth-seals the whole. Receiver reverses:
subgroup-check + AuthDecap + AEAD-Open (needs sender pub) -> verify BLS sig -> expiry check -> anti-replay
check -> decompress.

## 6. Threat model (NORMATIVE summary)

| Threat | Mitigation |
|---|---|
| Curious/compromised relay reads content | DHKEM-over-G1 AUTH-mode seal bound to recipient + sender BLS G1 keys; opening needs the recipient private key AND the sender public key; relay sees only ciphertext + routing metadata (section 5.1) |
| On-path tampering of routing metadata | Cleartext header bound as AEAD AAD (section 5.2) |
| Sender spoofing / signature-stripping | MANDATORY BLS (0x0010) signature on EVERY message/frame over the full transcript, verified against the resolved DID; unsigned/bad-sig -> REJECT fail-closed (section 5.1/5.6) |
| Sender impersonation at the KEM / forged-origin ciphertext | G1 auth mode binds the ciphertext to the sender static G1 key; a party lacking the sender scalar cannot produce an envelope that opens under (recipient_sk, sender_pub) (section 5.1) |
| Confused deputy: dig-message sig replayed as a chain spend | Domain tag SIG_DOMAIN + the identity signing key secures NO coins (defeats AGG_SIG_UNSAFE); a dig-message sig authorizes no spend and never verifies as AGG_SIG_ME (section 5.1a) |
| BLS key reuse (sign G2 vs DH G1 on one scalar) | Different groups + distinct KDF/augment domains; neither use is an oracle for the other; accepted residual (section 5.7) |
| Small-subgroup / invalid-curve on the seal KEM | Mandatory G1 subgroup + non-identity check on kem_enc and the resolved sender_pub before any DH (section 5.1) |
| Deniability vs non-repudiation | The seal AEAD is deniable (recipient-only auth); the BLS sig adds transferable non-repudiation — both intentionally present (section 5.7) |
| Type-confusion / payload splicing | message_type + correlation_id re-bound inside the seal, checked == header (section 5.2) |
| Cross-recipient replay of a sealed message | Transcript binds recipient + kem_enc (section 5.1) |
| Stream chunk replay/reorder/injection | Monotonic seq = AEAD nonce; per-stream derived key (section 5.3) |
| Resource exhaustion (huge msg / unbounded stream) | MAX_ENVELOPE_BYTES / MAX_CHUNK_BYTES / credit window (section 1, 3) |
| Decompression bomb (small frame -> huge output OOM) | uncompressed_len declared + checked; output bounded to MAX_DECOMPRESSED_BYTES; abort on overrun/mismatch (section 1.1) |
| Message replay (resend a captured sealed message) | Signed+sealed per-sender monotonic counter + timestamp freshness window + bounded sliding-window dedup; duplicate/stale -> REJECT (section 5.6) |
| Stale / over-long-lived message | Sender-authenticated expires_at TTL: discard when now > expires_at; capped at MAX_MESSAGE_TTL_MS (section 5.6b) |
| Reflection / cross-session frame injection | Transcript binds recipient + kem_enc + stream_frame/seq; per-stream key bound to the fresh OPEN kem_enc; frame from another session/direction -> REJECT (section 5.3/5.6) |
| Anti-replay state exhaustion (nonce/Sybil flood) | Fixed per-sender bitmap window (no growth per counter) + LRU-capped sender table (MAX_TRACKED_SENDERS) + valid-DID-signature admission (section 5.6) |
| Compression side channel (CRIME/BREACH) | Per-message/per-chunk fresh compression + fresh seal context; no secret+attacker-data in one context; raw(id=0) for interleaved-secret payloads (section 5.5) |
| Unknown compression algorithm id | Clean reject (UNSUPPORTED_COMPRESSION) / drop, never panic or mis-decode (section 1.1) |
| Self-addressed (sender==recipient) degeneracy | G1 self-DH sk*(sk*G1) is a valid non-identity point; seal/sig/replay/envelope all handle it (section 5.6a) |
| Key rotation / stale key | sender_epoch (0x0013) disambiguates; receiver resolves the epoch key |
| Unknown/newer version or type | Clean reject/drop, never panic (section 2, 4) |
| Metadata leakage (who talks to whom) | Documented residual: envelope reveals sender/recipient DID + timing to the relay; out of base scope (a future sealed-sender/onion layer MAY reduce it) |

## 7. Conformance

An implementation conforms iff it (a) encodes/decodes every section 2/3 structure byte-identically to the
golden KATs; (b) seals/opens per section 5 (DHKEM-over-G1 auth mode) and passes the relay-ciphertext +
tamper + wrong-recipient + wrong-sender-pubkey + non-subgroup KATs; (c) drives the section 3 streaming
state machine incl. backpressure + half-close + cancel; (d) handles unknown version/type/compression-id
per section 1.1/2/4 without panic; (e) agrees byte-for-byte across the Rust and wasm/JS targets, INCLUDING
the zstd(id=1) compressed bytes for the pinned params (section 1.2) and the G1-DH/seal vectors;
(f) compresses before sealing per the section 1 pipeline and enforces the section 1.1 decompression-bomb
guard + the section 5.5 single-context compression boundary; (g) signs EVERY message/frame with the BLS
(0x0010) sender key (domain-separated per section 5.1a) and rejects any unsigned/bad-signature message
fail-closed; (h) enforces the section 5.6 anti-replay scheme (counter + freshness window + bounded
sliding-window dedup) and the section 5.6b expires_at TTL, passing their replay/stale/reorder/cross-session
/flood + within/past-expiry KATs; (i) uses ONE BLS keypair for both the G2 signature and the G1-DH seal
(no X25519/Ed25519) and round-trips the section 5.6a self-addressed (sender==recipient) case identically
to a distinct-party message. SPEC.md, docs.dig.net protocol page, and superproject SYSTEM.md MUST agree
(ecosystem section 4.2 layering).