From 26bd0f1c9fc8db116d317472b1c5cd9995b38b94 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 03:59:51 +0000 Subject: [PATCH 1/4] chore: anchor dig-node chat subsystem lane (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvage anchor for Lane B of the dig-chat epic (#768): the dig-node chat subsystem — chat message-type crate (band 0x0200 on dig-message) + chat.* RPC + dig-gossip transport wiring. Implementation follows on this branch. Refs #793, #768, #781 Co-Authored-By: Claude From bed6d15398320ce71cf79811c570ab8ef5e2260d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 04:29:37 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(chat):=20dig-node=20chat=20subsystem?= =?UTF-8?q?=20=E2=80=94=20sealed=20directed=20transport=20(#793)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the transport half of dig-chat (epic #793, Lane B): the node seals an app-supplied opaque DIGCHAT1 envelope into a dig-message envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends it (opcode 220), and surfaces inbound decoded ChatMessages via a poll RPC. - Add `dig-chat-protocol` as a workspace member crate: the five chat message types on dig-message's dig-chat band (0x0200), content-blind, with KATs. - Register the chat types into a MessageRegistry and add the `chat` subsystem module: seal_outbound / open_into_inbox / process_inbound_frame + a bounded per-node inbox and a monotonic anti-replay counter. - Wire `chat.send` (seal + directed send, returns message_id) and `chat.poll` (drain inbox) into the RPC dispatch and the OpenRPC method catalogue. - Double seal upholds NC-1: neither the DIGCHAT1 body nor the plaintext message id appears in the on-wire bytes; the node never parses chat content. - Bump the workspace version to 0.62.0 (minor) and document the subsystem in SPEC.md §5.5.2. The sealing-key directory (resolveSealingKey) is deferred: the app supplies the recipient sealing key + gossip peer_id, and the inbound sender-key resolver is caller-supplied. Co-Authored-By: Claude --- Cargo.lock | 17 +- Cargo.toml | 127 ++-- SPEC.md | 45 +- crates/dig-chat-protocol/Cargo.toml | 37 + crates/dig-chat-protocol/README.md | 9 + crates/dig-chat-protocol/SPEC.md | 152 +++++ crates/dig-chat-protocol/src/kinds.rs | 113 +++ crates/dig-chat-protocol/src/lib.rs | 55 ++ crates/dig-chat-protocol/src/types.rs | 185 +++++ crates/dig-chat-protocol/tests/kat.rs | 383 +++++++++++ crates/dig-node-core/Cargo.toml | 10 + crates/dig-node-core/src/chat.rs | 644 ++++++++++++++++++ crates/dig-node-core/src/lib.rs | 19 + .../src/seams/dig_rpc/dispatch.rs | 14 + crates/dig-node-service/src/meta.rs | 22 + 15 files changed, 1766 insertions(+), 66 deletions(-) create mode 100644 crates/dig-chat-protocol/Cargo.toml create mode 100644 crates/dig-chat-protocol/README.md create mode 100644 crates/dig-chat-protocol/SPEC.md create mode 100644 crates/dig-chat-protocol/src/kinds.rs create mode 100644 crates/dig-chat-protocol/src/lib.rs create mode 100644 crates/dig-chat-protocol/src/types.rs create mode 100644 crates/dig-chat-protocol/tests/kat.rs create mode 100644 crates/dig-node-core/src/chat.rs diff --git a/Cargo.lock b/Cargo.lock index ac80da3..c80d613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,6 +1924,18 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "dig-chat-protocol" +version = "0.1.0" +dependencies = [ + "chia-protocol", + "chia-sha2 0.26.0", + "chia-traits 0.26.0", + "chia_streamable_macro 0.26.0", + "dig-message", + "sha2 0.10.9", +] + [[package]] name = "dig-clvm" version = "0.1.1" @@ -2221,7 +2233,9 @@ dependencies = [ "chia-protocol", "chia-query 0.5.1", "chia-sdk-utils", + "chia-traits 0.26.0", "dig-chainsource-interface", + "dig-chat-protocol", "dig-constants 0.4.0", "dig-dht", "dig-download", @@ -2229,6 +2243,7 @@ dependencies = [ "dig-identity 0.4.0", "dig-ip", "dig-ipc-protocol", + "dig-message", "dig-nat", "dig-peer", "dig-peer-selector", @@ -2266,7 +2281,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.73.0" +version = "0.74.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5c9fa4d..13c549a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,63 +1,64 @@ -[workspace] -resolver = "2" -# The canonical dig-node repo is a small workspace of the node ENGINE + its two -# DIG-Browser host shells: -# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, -# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, -# cache, P2P. The single node implementation shared by BOTH host -# shells below. (Renamed from `dig-node` so the engine library and -# the produced `dig-node` binary no longer share a name, #216.) -# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control -# plane + CLI + service install. Depends on the engine library. -# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) -# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. -# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS -# signing), brought up by dig-runtime beside the node. -# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates -# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core -# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. -members = [ - "crates/dig-node-core", - "crates/dig-node-service", - "crates/dig-runtime", - "crates/dig-wallet", -] - -[workspace.package] -edition = "2021" -# The RELEASE version of the repo's shipped artifact — the `dig-node` binary -# (`dig-node-service`, which inherits this via `version.workspace = true`). This is -# the version the nightly-release.yml stable channel + version-increment CI reads from -# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a -# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) -# keep their own independent versions — only the released binary tracks the workspace version. -version = "0.73.0" - -# Release hardening, matching digstore: keep integer-overflow checks ON in release. -# The node parses untrusted serialized input and does offset/length arithmetic over -# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. -[profile.release] -overflow-checks = true - -# -- dig-gossip vendored-fork patches (L7 peer network) ----------------------------------------------- -# -# The node's P2P stack (dig-nat/dig-gossip/dig-dht/dig-pex/dig-download/dig-peer-selector) builds against -# ADDITIVE forks of `chia-protocol` + `chia-sdk-client` that dig-gossip vendors: the DIG introducer -# opcodes `RegisterPeer=218`/`RegisterAck=219` on `ProtocolMessageTypes`, plus `Peer::send_protocol_message` -# + `RateLimits.dig_wire`. Those forks are the SAME upstream versions (`chia-protocol` 0.26, -# `chia-sdk-client` 0.28) with additive DIG extensions, so patching the whole workspace to them is safe -# for the store-format crates too (they simply gain unused enum variants). -# -# dig-gossip's own `[patch.crates-io]` does NOT apply transitively when it is a git DEPENDENCY (cargo -# applies patches only from the ROOT manifest being built), so this workspace re-declares them, resolving -# the vendored packages from the SAME pinned dig-gossip git rev — CI-safe when the repo is checked out -# standalone. `native-tls` is NOT patched: dig-gossip is pulled with -# `default-features = false, features = ["rustls", "relay"]`, so the OpenSSL/native-tls path is off. -[patch.crates-io] -chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "6d458974522cdada1f9b09469d7709e08036a800" } -chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "6d458974522cdada1f9b09469d7709e08036a800" } - -# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): dig-nat 0.7 is on crates.io and -# the ENTIRE peer stack (dig-gossip 0.7.1, dig-dht 0.2.2, dig-download 0.2.1, dig-peer-selector 0.2.1, -# dig-node-core) now depends on dig-nat "0.7" from crates.io, so cargo already resolves ONE dig-nat 0.7 -# instance without any git redirect. dig-constants is likewise a plain crates.io dep everywhere now. +[workspace] +resolver = "2" +# The canonical dig-node repo is a small workspace of the node ENGINE + its two +# DIG-Browser host shells: +# * dig-node-core — the NODE engine library (crate `dig_node_core`): RPC dispatch, +# serve/fetch/redirect, chain-watch, subscriptions, gap-fill, +# cache, P2P. The single node implementation shared by BOTH host +# shells below. (Renamed from `dig-node` so the engine library and +# the produced `dig-node` binary no longer share a name, #216.) +# * dig-node-service — the OS-service binary (`dig-node`): axum transport + control +# plane + CLI + service install. Depends on the engine library. +# * dig-runtime — the DIG Browser's in-process node: a cdylib (`dig_runtime.dll`) +# exposing the `dig_rpc`/`dig_wallet_rpc` C-ABI the browser links. +# * dig-wallet — the DIG Browser's built-in Chia wallet host (loopback UI + BLS +# signing), brought up by dig-runtime beside the node. +# For the `.dig` STORE FORMAT the node depends on digstore's store-format LIBRARY crates +# (digstore-core/-crypto/-chain/-host/-remote/-stage) as GIT dependencies — dig-node-core +# -> store-lib, never the reverse. digstore is only ever an RPC client of a node. +members = [ + "crates/dig-node-core", + "crates/dig-chat-protocol", + "crates/dig-node-service", + "crates/dig-runtime", + "crates/dig-wallet", +] + +[workspace.package] +edition = "2021" +# The RELEASE version of the repo's shipped artifact — the `dig-node` binary +# (`dig-node-service`, which inherits this via `version.workspace = true`). This is +# the version the nightly-release.yml stable channel + version-increment CI reads from +# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a +# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) +# keep their own independent versions — only the released binary tracks the workspace version. +version = "0.74.0" + +# Release hardening, matching digstore: keep integer-overflow checks ON in release. +# The node parses untrusted serialized input and does offset/length arithmetic over +# it, so silent wrapping in release would turn a length bug into a memory/logic hazard. +[profile.release] +overflow-checks = true + +# -- dig-gossip vendored-fork patches (L7 peer network) ----------------------------------------------- +# +# The node's P2P stack (dig-nat/dig-gossip/dig-dht/dig-pex/dig-download/dig-peer-selector) builds against +# ADDITIVE forks of `chia-protocol` + `chia-sdk-client` that dig-gossip vendors: the DIG introducer +# opcodes `RegisterPeer=218`/`RegisterAck=219` on `ProtocolMessageTypes`, plus `Peer::send_protocol_message` +# + `RateLimits.dig_wire`. Those forks are the SAME upstream versions (`chia-protocol` 0.26, +# `chia-sdk-client` 0.28) with additive DIG extensions, so patching the whole workspace to them is safe +# for the store-format crates too (they simply gain unused enum variants). +# +# dig-gossip's own `[patch.crates-io]` does NOT apply transitively when it is a git DEPENDENCY (cargo +# applies patches only from the ROOT manifest being built), so this workspace re-declares them, resolving +# the vendored packages from the SAME pinned dig-gossip git rev — CI-safe when the repo is checked out +# standalone. `native-tls` is NOT patched: dig-gossip is pulled with +# `default-features = false, features = ["rustls", "relay"]`, so the OpenSSL/native-tls path is off. +[patch.crates-io] +chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "6d458974522cdada1f9b09469d7709e08036a800" } +chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "6d458974522cdada1f9b09469d7709e08036a800" } + +# The dig-nat unification patch is RETIRED (#1280 crates.io cascade): dig-nat 0.7 is on crates.io and +# the ENTIRE peer stack (dig-gossip 0.7.1, dig-dht 0.2.2, dig-download 0.2.1, dig-peer-selector 0.2.1, +# dig-node-core) now depends on dig-nat "0.7" from crates.io, so cargo already resolves ONE dig-nat 0.7 +# instance without any git redirect. dig-constants is likewise a plain crates.io dep everywhere now. diff --git a/SPEC.md b/SPEC.md index 2feb833..2f628dd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -844,8 +844,9 @@ For the current node library (§2.2) the catalogue is: - **local**: `dig.getContent`, `dig.getAnchoredRoot`, `dig.getManifest`, `dig.stage`, `dig.getCollection`, `dig.listCollectionItems`, the L7 peer surface (`dig.getNetworkInfo`, `dig.getPeers`, `dig.announce`, `dig.getAvailability`, `dig.listInventory`, `dig.fetchRange`), - and all `cache.*` (`cache.getConfig`, `cache.setCapBytes`, `cache.clear`, `cache.listCached`, - `cache.removeCached`, `cache.fetchAndCache`). + all `cache.*` (`cache.getConfig`, `cache.setCapBytes`, `cache.clear`, `cache.listCached`, + `cache.removeCached`, `cache.fetchAndCache`), and the chat subsystem `chat.send` / `chat.poll` + (§5.5.2). - **passthrough**: `dig.getCapsule` (an alias the node does NOT resolve — local-first callers use `dig.getContent`), `dig.getProof`, `dig.listCapsules`. - **shell**: `rpc.discover`. @@ -881,6 +882,46 @@ version per path) as of a given capsule's commit. PUBLIC, unencrypted data; no ` from the "held but no manifest" case above. - Malformed `store_id`/`root` (not 64-hex) → `-32602` before any filesystem access. +#### 5.5.2. Chat subsystem — `chat.send` / `chat.poll` (epic #793) + +The node is the directed-message **TRANSPORT** for dig-chat: an application seals its own opaque +`DIGCHAT1` message body and the node wraps that blob in an e2e-sealed `dig-message` envelope +addressed to the recipient's `0x0010` BLS identity key, then dig-gossip directed-sends it over +opcode 220 (`DIG_MESSAGE`). The node NEVER parses the `DIGCHAT1` body — it is carried verbatim in +`dig_chat_protocol::ChatMessage::envelope` (message type id `0x0000_0200`, dig-message's dig-chat +band). + +**Double seal (NC-1, content-blindness).** Two independent seals stack: the inner `DIGCHAT1` seal +the app applies, and the outer `dig-message` seal to the recipient's BLS key. A relay or on-path +peer sees only the outer ciphertext; a peer that terminates the outer seal still faces the inner +one. The node cannot expose chat plaintext even in principle. A conformance test asserts neither +the plaintext body nor the plaintext message id appears in the on-wire sealed bytes. + +- **`chat.send`** — seal + directed-send. Params `{ recipient_did (64-hex), recipient_pub (base64, + the recipient's 48-byte BLS G1 sealing key), peer_id (64-hex, the gossip directed-send target), + envelope (base64, the opaque `DIGCHAT1` bytes) }`. Result `{ message_id }` (64-hex, a + node-minted `SHA-256(sender_did ‖ counter ‖ envelope)`). Each send stamps a strictly-monotonic + per-node anti-replay counter and a 5-minute expiry (dig-message §5.6/§5.6b). Errors: `-32050` + (no node identity key), `-32602` (missing/malformed param), `-32051` (no peer network), `-32052` + (seal or directed-send failed). The node seals as the node identity DID + `SHA-256(node BLS G1 public key)`. +- **`chat.poll`** — drain the inbound inbox. No params. Result `{ messages: [{ sender_did (64-hex, + the verified envelope sender), message_id (64-hex), envelope (base64, the opaque `DIGCHAT1` + body) }] }`, in arrival order, leaving the inbox empty. The inbox is bounded (oldest evicted at + capacity) so a paired peer cannot grow node memory without bound. + +**Inbound path.** A received opcode-220 frame is opened (`dig-message` unseal → BLS-G2 signature +verify → anti-replay → expiry), routed through the chat `MessageRegistry`, and the decoded +`ChatMessage` is queued into the inbox. A sender the node cannot resolve to a BLS key is rejected, +never queued (fail-closed). + +**Deferred (epic #793):** the sealing-key directory (`resolveSealingKey`) that maps a recipient +DID to its attested `0x0010` BLS key + gossip `PeerId`, and the inbound sender-key resolver, are +NOT in this MVP — the app supplies `recipient_pub` + `peer_id` on `chat.send`, and the inbound +resolver is caller-supplied. Group chat, onion routing, and receipt UX are out of scope; the five +chat message types (message, delivery/read receipts, typing, presence) are defined in +`dig-chat-protocol` but only `ChatMessage` surfaces to `chat.poll`. + ### 5.6. OpenRPC drift guard (conformance test) `tests/openrpc_drift_guard.rs` pins the catalogue to reality and MUST be kept passing: diff --git a/crates/dig-chat-protocol/Cargo.toml b/crates/dig-chat-protocol/Cargo.toml new file mode 100644 index 0000000..3ffeec2 --- /dev/null +++ b/crates/dig-chat-protocol/Cargo.toml @@ -0,0 +1,37 @@ +# dig-chat-protocol — the DIG Network chat message-TYPE layer. +# +# A PURE message-type layer riding the published `dig-message` base protocol: it defines the five +# chat payload types in dig-message's reserved dig-chat band (0x0000_0200..=0x0000_02FF) and registers +# them into a `MessageRegistry`. It contains NO cryptography — dig-message provides all seal / sign / +# replay / streaming, and the dig-chat application provides the content seal (`DIGCHAT1`, carried here +# as OPAQUE bytes and never parsed). See SPEC.md. +[package] +name = "dig-chat-protocol" +version = "0.1.0" +edition = "2021" +rust-version = "1.75.0" +license = "Apache-2.0 OR MIT" +description = "The DIG Network chat message-type layer: the five chat payload types (message, delivery/read receipts, typing, presence) defined in dig-message's reserved dig-chat band and registered into its type registry. A crypto-free, content-blind layer — the DIGCHAT1 content seal is carried as opaque bytes; dig-message provides all seal/sign/replay/streaming." +repository = "https://github.com/DIG-Network/dig-node" +readme = "README.md" +keywords = ["dig", "chat", "messaging", "protocol", "streamable"] +categories = ["network-programming", "encoding"] + +[dependencies] +# The base message protocol this crate rides: the envelope, the type registry (`MessageKind` / +# `MessageRegistry` / `MessageBand`), the reserved dig-chat band, and the error taxonomy. All seal / +# sign / replay / streaming crypto lives HERE, never in this crate. Pinned to the SAME dig-message the +# dig-node engine seals with (0.5), so the type ids + envelope agree byte-for-byte across the workspace. +dig-message = "0.5" +# The byte-deterministic Chia Streamable wire types + the `Streamable` derive + the to_bytes/from_bytes +# trait — the SAME source + versions dig-message uses, so the payload bytes agree byte-for-byte. +chia-protocol = "0.26" +chia-traits = "0.26" +chia_streamable_macro = "0.26" +# The `Streamable` derive expands to `chia_sha2`-based `hash()` helpers; it must be a direct dep so the +# generated code resolves it (the same requirement dig-message carries). +chia-sha2 = "0.26" + +[dev-dependencies] +# Deterministic KAT material derived from a hashed seed — never a hard-coded literal (CodeQL). +sha2 = "0.10" diff --git a/crates/dig-chat-protocol/README.md b/crates/dig-chat-protocol/README.md new file mode 100644 index 0000000..d6512bd --- /dev/null +++ b/crates/dig-chat-protocol/README.md @@ -0,0 +1,9 @@ +# dig-chat-protocol + +The DIG Network chat message-TYPE layer: the five chat payload types — `ChatMessage`, +`DeliveryReceipt`, `ReadReceipt`, `TypingIndicator`, `Presence` — defined in `dig-message`'s reserved +dig-chat band (`0x0000_0200..=0x0000_02FF`) and registered into its `MessageRegistry`. + +It is a crypto-free, content-blind layer: `ChatMessage::envelope` carries the opaque `DIGCHAT1` +content seal verbatim and is never parsed here; `dig-message` provides all seal / sign / replay / +streaming. See `SPEC.md`. diff --git a/crates/dig-chat-protocol/SPEC.md b/crates/dig-chat-protocol/SPEC.md new file mode 100644 index 0000000..2ec0e26 --- /dev/null +++ b/crates/dig-chat-protocol/SPEC.md @@ -0,0 +1,152 @@ +# dig-chat-protocol — normative specification + +The authoritative contract for the DIG Network chat message-TYPE layer. An independent +reimplementation can be built against this document alone. Normative keywords MUST / SHOULD / MAY are +used in their RFC 2119 sense. + +## §1 Scope + +`dig-chat-protocol` is a **pure message-type layer** riding the `dig-message` base protocol +(`dig-message = "0.5"`). It defines the chat payload types allocated in dig-message's reserved +**dig-chat band** `0x0000_0200..=0x0000_02FF` (`MessageBand::DigChat`) and registers them into a +`dig_message::MessageRegistry`. That is its entire responsibility. + +This crate contains **NO cryptography**. Confidentiality, authenticity, integrity, anti-replay, and +streaming are provided by two layers ABOVE and BELOW it, not here: + +- **Below (the transport seal):** `dig-message` seals every directed message end-to-end to the + recipient key, signs it with the sender's BLS key, enforces the anti-replay window, and drives the + streaming state machine. See the dig-message SPEC. +- **Above (the content seal):** the dig-chat application seals message content into an opaque + `DIGCHAT1` blob (dig-chat app SPEC §4) before it is ever handed to this layer. This crate carries + that blob as opaque bytes in `ChatMessage.envelope` and **MUST NOT parse, inspect, or transform + it**. + +This double-seal (content `DIGCHAT1` inner + `dig-message` transport outer) is why the type layer is +crypto-free: it is content-blind by construction and therefore cannot expose chat plaintext, even in +principle. This satisfies the ecosystem end-to-end-encryption invariant (**NC-1**, CLAUDE.md §5.4): +directed chat is e2e-encrypted to the recipient; a relay or node that terminates the mTLS pipe still +sees only ciphertext. + +**Out of scope (deliberately, MVP one-shot shape):** conversation open/close types, a directory / +key-lookup type. A 1:1 conversation is correlation by peer DID; recipient-key resolution is a +dig-node RPC. No such types are defined here. + +Cross-references: `dig-message` SPEC §4 (the type registry + bands), dig-chat app SPEC §4 (the +`DIGCHAT1` envelope), the superproject `SYSTEM.md` (the cross-repo interaction map), the +`normative-contract` skill NC-1. + +## §2 Message types + +Five payload types are defined, allocated **contiguously** from the base of the dig-chat band. Each is +a Chia-`Streamable` struct (§3). The id assignment is **additive-only**: an id, once assigned, is +never renumbered, removed, or repurposed; new types take the next free id in the band. + +| id (`u32`) | Type | `Streamable` payload fields (in wire order) | +|---|---|---| +| `0x0000_0200` | `ChatMessage` | `message_id: Bytes32`, `envelope: Vec` | +| `0x0000_0201` | `DeliveryReceipt` | `message_id: Bytes32`, `status: u8` | +| `0x0000_0202` | `ReadReceipt` | `message_id: Bytes32` | +| `0x0000_0203` | `TypingIndicator` | `conversation_id: Bytes32`, `state: u8` | +| `0x0000_0204` | `Presence` | `state: u8` | + +Field semantics: + +- **`ChatMessage.envelope`** — the OPAQUE `DIGCHAT1` content seal. The sole content-bearing field in + the layer. Carried verbatim; MAY be any length including empty; never parsed here. +- **`ChatMessage.message_id` / `DeliveryReceipt.message_id` / `ReadReceipt.message_id`** — the + application-assigned id of the chat message a receipt refers to. +- **`DeliveryReceipt.status`** — a `DeliveryStatus` discriminant: `Delivered = 0`, `Failed = 1`. +- **`TypingIndicator.conversation_id`** — the conversation the indicator applies to. +- **`TypingIndicator.state`** — a `TypingState` discriminant: `Started = 0`, `Stopped = 1`. +- **`Presence.state`** — a `PresenceState` discriminant: `Online = 0`, `Away = 1`, `Offline = 2`. + +**Enum discriminants are `u8` on the wire.** The named enums are the typed, validated view of that +`u8`. A reader MUST surface an unrecognized discriminant as a clean, typed error (`ChatEnumError`) and +MUST NOT panic. New enum variants are additive (a new discriminant); existing discriminants are never +renumbered. Because the wire carries a plain `u8`, a struct with an unknown discriminant still decodes +structurally — the value is validated only when the typed accessor (`status()` / `state()`) is called, +so an old reader tolerates a new-writer discriminant it does not recognize. + +## §3 Wire determinism + +Each payload is encoded with the Chia `Streamable` contract (`chia-traits` / `chia_streamable_macro`, +version `0.26`, the SAME source and version `dig-message` uses — the bytes MUST agree byte-for-byte): + +- Fields are serialized in declaration order with no tags, names, or padding. +- `Bytes32` is 32 raw bytes. +- `Vec` is a 4-byte big-endian length prefix followed by the raw bytes. +- `u8` is one byte. + +The encoding is therefore fully deterministic. The golden byte-vectors (§5) pin it: a change to any +committed digest is a wire-format break and MUST be an intentional, reviewed SemVer event. + +## §4 Registration + +`register_all(registry: &mut MessageRegistry, handler: Arc) -> dig_message::Result<()>` +registers all five types into the registry, each decoding to its `Streamable` payload and dispatching +to the matching `ChatHandler` method. + +- **Additive.** Registration adds the five ids without disturbing any handlers already present. +- **Duplicate-refused.** If any of the five ids is already registered, `register_all` returns + `dig_message::MessageError::DuplicateType(id)` rather than overwriting the existing handler (SPEC §2 + additive-only). Registration is not transactional; call it once on a fresh registry. +- **Unknown-type rule (inherited from dig-message).** An id within the dig-chat band that has no + registered handler (e.g. `0x0000_02FF`) is dispatched per `MessageRegistry::dispatch`: a + request/stream shape returns `MessageError::UnsupportedType`, a one-shot/response shape is silently + dropped (`Dispatch::Dropped`). Dispatch NEVER panics on an unknown type. This is the forward- + compatibility property: an old reader keeps working when a newer sender introduces a new chat type. + +## §5 Known-Answer Tests (conformance vectors) + +The test suite (`tests/kat.rs`) pins the contract. All test material is derived from a hashed seed +(`SHA-256(tag ‖ counter)`), never a hard-coded literal. + +1. **Golden byte-vectors** — for each of the five payloads, the deterministic on-wire encoding has a + fixed length and a committed SHA-256 digest, and `encode → bytes → decode` round-trips byte- + identically. Committed vectors (seed-derived fields): + - `ChatMessage` — 76 bytes, `sha256 = 1242949e1b33fe2c73f546f132c894d3a6a118499cbc1e0d24c54dbec306a984`. + - `DeliveryReceipt` — 33 bytes, `sha256 = e3a3d3eebc8e5eeb2fb539366ad4f9c45ec7dcfa8b7735ac38a37476fc24bcf1`. + - `ReadReceipt` — 32 bytes, `sha256 = 5e79288de830117c1a6fa9ce8efad1ccb8ad3a699a2b71c1c74cb3c66fd79958`. + - `TypingIndicator` — 33 bytes, `sha256 = 2a9bb74d921dfcf1863eadd79dd2041f21a51985f2c27d3f14d8b700cae8f576`. + - `Presence` — 1 byte, `sha256 = 4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a`. +2. **Band membership** — every id's `.band()` is `MessageBand::DigChat`, and the ids are exactly + `0x0200..=0x0204`, contiguous. +3. **Routing + duplicate refusal** — `register_all` then `dispatch` routes each id to the correctly + decoded kind; a second `register_all` on the same registry returns `DuplicateType`; a + pre-populated registry is left undisturbed. +4. **Unknown in-band id** — `0x02FF` follows the §4 unknown-type rule (drop / UnsupportedType), no + panic. +5. **`DIGCHAT1` passthrough** — a `ChatMessage` round-trips an ARBITRARY opaque `envelope` (empty, + single-byte, and `> 64 KiB` random) byte-identically — proving content-blindness. +6. **Enum reject** — an out-of-range `status` / `state` byte decodes structurally, then the typed + accessor returns `ChatEnumError` (never a panic); valid discriminants map to their variants. + +## §6 Threat notes + +- **Confidentiality is delegated, twice.** Chat content confidentiality is provided by the inner + `DIGCHAT1` seal (dig-chat app) and the outer `dig-message` transport seal. This layer's ONLY + security obligation is to **never expose plaintext**, and it discharges that obligation + *structurally*: it carries only opaque bytes, 32-byte ids, and small enum discriminants — there is + no field into which chat plaintext could be placed unsealed. +- **Metadata residual (stated, not mitigated here).** `DeliveryReceipt`, `ReadReceipt`, + `TypingIndicator`, and `Presence` carry no content, but they ARE metadata (who is talking to whom, + when, and read/typing/presence state). They are protected only by the `dig-message` transport seal + (mTLS pipe + e2e envelope), not by an additional content seal. A party that can read the opened + `dig-message` payload learns this metadata. Reducing metadata exposure (e.g. sealed-sender, padding, + cover traffic) is out of scope for this type layer and is a dig-message / dig-chat-app concern. +- **No panics on adversarial input.** Every value read off the wire (unknown type id, out-of-range + enum discriminant, truncated payload) fails cleanly through `dig_message::MessageError` / + `ChatEnumError`; the layer never panics on hostile input. + +## §7 Conformance + +An implementation conforms iff: + +1. It defines exactly the five types at exactly the ids in §2, encoded per §3. +2. It reproduces every §5 golden digest byte-for-byte. +3. Its registration is additive and duplicate-refusing, and unknown in-band ids follow the §4 + unknown-type rule without panicking (§5.3, §5.4). +4. A `ChatMessage` round-trips an arbitrary opaque `envelope` byte-identically (§5.5), and it never + parses the `envelope`. +5. Out-of-range enum discriminants are surfaced as clean errors, never panics (§5.6). diff --git a/crates/dig-chat-protocol/src/kinds.rs b/crates/dig-chat-protocol/src/kinds.rs new file mode 100644 index 0000000..af307c6 --- /dev/null +++ b/crates/dig-chat-protocol/src/kinds.rs @@ -0,0 +1,113 @@ +//! Binding the five chat payloads to `dig-message`'s type registry (SPEC §4). +//! +//! Each payload declares a [`MessageKind`] whose `TYPE_ID` is its reserved id in the dig-chat band +//! (`0x0000_0200..=0x0000_02FF`) and whose `Payload` is the Streamable struct from [`crate::types`]. +//! The ids are contiguous from [`BAND_DIG_CHAT`] and additive-only: an id, once assigned, is never +//! renumbered or repurposed (SPEC §2, §4). +//! +//! [`register_all`] wires all five into a [`MessageRegistry`] against a caller-supplied +//! [`ChatHandler`]. It refuses duplicates by surfacing dig-message's [`MessageError::DuplicateType`] +//! (never overwriting an existing handler), and it leaves dig-message's unknown-type rule intact — an +//! unregistered in-band id (e.g. `0x02FF`) dispatches per [`MessageRegistry::dispatch`] +//! (UNSUPPORTED_TYPE for a request/stream shape, a silent drop for a one-shot/response; never a panic). + +use std::sync::Arc; + +use dig_message::{MessageKind, MessageRegistry, MessageType, Result, BAND_DIG_CHAT}; + +use crate::types::{ChatMessage, DeliveryReceipt, Presence, ReadReceipt, TypingIndicator}; + +/// The reserved id for [`ChatMessage`] (SPEC §2). +pub const ID_CHAT_MESSAGE: MessageType = MessageType(BAND_DIG_CHAT); +/// The reserved id for [`DeliveryReceipt`] (SPEC §2). +pub const ID_DELIVERY_RECEIPT: MessageType = MessageType(BAND_DIG_CHAT + 1); +/// The reserved id for [`ReadReceipt`] (SPEC §2). +pub const ID_READ_RECEIPT: MessageType = MessageType(BAND_DIG_CHAT + 2); +/// The reserved id for [`TypingIndicator`] (SPEC §2). +pub const ID_TYPING_INDICATOR: MessageType = MessageType(BAND_DIG_CHAT + 3); +/// The reserved id for [`Presence`] (SPEC §2). +pub const ID_PRESENCE: MessageType = MessageType(BAND_DIG_CHAT + 4); + +/// The [`MessageKind`] for [`ChatMessage`] (id `0x0200`). +pub struct ChatMessageKind; +impl MessageKind for ChatMessageKind { + const TYPE_ID: MessageType = ID_CHAT_MESSAGE; + type Payload = ChatMessage; +} + +/// The [`MessageKind`] for [`DeliveryReceipt`] (id `0x0201`). +pub struct DeliveryReceiptKind; +impl MessageKind for DeliveryReceiptKind { + const TYPE_ID: MessageType = ID_DELIVERY_RECEIPT; + type Payload = DeliveryReceipt; +} + +/// The [`MessageKind`] for [`ReadReceipt`] (id `0x0202`). +pub struct ReadReceiptKind; +impl MessageKind for ReadReceiptKind { + const TYPE_ID: MessageType = ID_READ_RECEIPT; + type Payload = ReadReceipt; +} + +/// The [`MessageKind`] for [`TypingIndicator`] (id `0x0203`). +pub struct TypingIndicatorKind; +impl MessageKind for TypingIndicatorKind { + const TYPE_ID: MessageType = ID_TYPING_INDICATOR; + type Payload = TypingIndicator; +} + +/// The [`MessageKind`] for [`Presence`] (id `0x0204`). +pub struct PresenceKind; +impl MessageKind for PresenceKind { + const TYPE_ID: MessageType = ID_PRESENCE; + type Payload = Presence; +} + +/// The five reserved dig-chat ids, in contiguous order from [`BAND_DIG_CHAT`] (SPEC §2). Useful for +/// tests and for enumerating the layer's surface. +pub const CHAT_MESSAGE_TYPES: [MessageType; 5] = [ + ID_CHAT_MESSAGE, + ID_DELIVERY_RECEIPT, + ID_READ_RECEIPT, + ID_TYPING_INDICATOR, + ID_PRESENCE, +]; + +/// The application's handlers for each decoded chat payload (SPEC §4). A consumer implements this once +/// and passes it to [`register_all`]; the registry decodes the on-wire bytes into the typed payload +/// before invoking the matching method. Each method returns [`Result`] so a handler-side failure +/// propagates through dispatch unchanged. +pub trait ChatHandler: Send + Sync + 'static { + /// Handle a decoded [`ChatMessage`] (its `envelope` is still the opaque `DIGCHAT1` seal). + fn on_chat_message(&self, message: ChatMessage) -> Result<()>; + /// Handle a decoded [`DeliveryReceipt`]. + fn on_delivery_receipt(&self, receipt: DeliveryReceipt) -> Result<()>; + /// Handle a decoded [`ReadReceipt`]. + fn on_read_receipt(&self, receipt: ReadReceipt) -> Result<()>; + /// Handle a decoded [`TypingIndicator`]. + fn on_typing_indicator(&self, indicator: TypingIndicator) -> Result<()>; + /// Handle a decoded [`Presence`] announcement. + fn on_presence(&self, presence: Presence) -> Result<()>; +} + +/// Register all five chat message types into `registry`, dispatching each to `handler` (SPEC §4). +/// +/// Registration is additive: the five ids are added without disturbing any pre-existing handlers, and +/// any id already present makes the whole call fail rather than overwrite. +/// +/// # Errors +/// [`MessageError::DuplicateType`](dig_message::MessageError::DuplicateType) if any of the five ids is +/// already registered (SPEC §4 additive-only). On error, ids registered earlier in the call remain — +/// registration is not transactional; call it once on a fresh registry. +pub fn register_all(registry: &mut MessageRegistry, handler: Arc) -> Result<()> { + let h = Arc::clone(&handler); + registry.register::(move |m| h.on_chat_message(m))?; + let h = Arc::clone(&handler); + registry.register::(move |m| h.on_delivery_receipt(m))?; + let h = Arc::clone(&handler); + registry.register::(move |m| h.on_read_receipt(m))?; + let h = Arc::clone(&handler); + registry.register::(move |m| h.on_typing_indicator(m))?; + registry.register::(move |m| handler.on_presence(m))?; + Ok(()) +} diff --git a/crates/dig-chat-protocol/src/lib.rs b/crates/dig-chat-protocol/src/lib.rs new file mode 100644 index 0000000..9dea6c9 --- /dev/null +++ b/crates/dig-chat-protocol/src/lib.rs @@ -0,0 +1,55 @@ +//! # dig-chat-protocol — the DIG Network chat message-TYPE layer +//! +//! A PURE message-type layer riding the published [`dig_message`] base protocol. It defines the five +//! chat payload types in dig-message's reserved dig-chat band (`0x0000_0200..=0x0000_02FF`) and +//! registers them into a [`dig_message::MessageRegistry`]. That is the ENTIRE responsibility. +//! +//! ## What this crate deliberately does NOT do +//! - **No cryptography.** dig-message provides the outer e2e seal, the BLS sender signature, the +//! anti-replay window, and the streaming state machine. This crate defines types only. +//! - **No content parsing.** [`ChatMessage::envelope`] is the OPAQUE `DIGCHAT1` content seal produced +//! by the dig-chat application; this layer carries it verbatim and never inspects it. The protocol +//! is therefore content-blind by construction — it cannot expose chat plaintext even in principle +//! (SPEC §1, §6; NC-1). +//! - **No conversation/directory types.** A 1:1 conversation is correlation by peer DID and key +//! resolution is a dig-node RPC — both out of scope for this one-shot message-type layer (SPEC §1). +//! +//! ## Using it +//! Implement [`ChatHandler`] and call [`register_all`] once on a [`dig_message::MessageRegistry`]; the +//! registry then decodes and routes each incoming chat type to the matching handler method. Unknown +//! in-band ids follow dig-message's unknown-type rule (never a panic). +//! +//! ``` +//! use std::sync::Arc; +//! use dig_message::MessageRegistry; +//! use dig_chat_protocol::{register_all, ChatHandler, ChatMessage, DeliveryReceipt, Presence, +//! ReadReceipt, TypingIndicator}; +//! +//! struct Sink; +//! impl ChatHandler for Sink { +//! fn on_chat_message(&self, _m: ChatMessage) -> dig_message::Result<()> { Ok(()) } +//! fn on_delivery_receipt(&self, _m: DeliveryReceipt) -> dig_message::Result<()> { Ok(()) } +//! fn on_read_receipt(&self, _m: ReadReceipt) -> dig_message::Result<()> { Ok(()) } +//! fn on_typing_indicator(&self, _m: TypingIndicator) -> dig_message::Result<()> { Ok(()) } +//! fn on_presence(&self, _m: Presence) -> dig_message::Result<()> { Ok(()) } +//! } +//! +//! let mut registry = MessageRegistry::new(); +//! register_all(&mut registry, Arc::new(Sink)).unwrap(); +//! assert_eq!(registry.len(), 5); +//! ``` + +#![forbid(unsafe_code)] + +pub mod kinds; +pub mod types; + +pub use kinds::{ + register_all, ChatHandler, ChatMessageKind, DeliveryReceiptKind, PresenceKind, ReadReceiptKind, + TypingIndicatorKind, CHAT_MESSAGE_TYPES, ID_CHAT_MESSAGE, ID_DELIVERY_RECEIPT, ID_PRESENCE, + ID_READ_RECEIPT, ID_TYPING_INDICATOR, +}; +pub use types::{ + ChatEnumError, ChatMessage, DeliveryReceipt, DeliveryStatus, Presence, PresenceState, + ReadReceipt, TypingIndicator, TypingState, +}; diff --git a/crates/dig-chat-protocol/src/types.rs b/crates/dig-chat-protocol/src/types.rs new file mode 100644 index 0000000..3d886a3 --- /dev/null +++ b/crates/dig-chat-protocol/src/types.rs @@ -0,0 +1,185 @@ +//! The five chat payload types (SPEC §2) and their status/state enums. +//! +//! Each payload is a Chia-[`Streamable`](chia_traits::Streamable) struct so its bytes are byte- +//! deterministic across every target (SPEC §3) — the derive lays the fields out in declaration order +//! with no padding or self-describing tags, exactly as `dig-message`'s own payloads do. +//! +//! ## Content-blindness (the load-bearing property, SPEC §1, §6) +//! The ONLY content-bearing field in the whole layer is [`ChatMessage::envelope`]: an OPAQUE +//! `DIGCHAT1` blob sealed by the dig-chat application to the recipient's key. This crate NEVER parses +//! it — it carries the bytes verbatim, so the protocol layer cannot expose chat plaintext even in +//! principle. Receipts, typing, and presence carry only ids and small enum discriminants (metadata). +//! +//! ## Enums vs. the wire (SPEC §2) +//! `status`/`state` are `u8` ON THE WIRE (a fixed-width, forward-compatible discriminant) but are +//! surfaced as real Rust enums via [`TryFrom`]. An unknown discriminant is rejected as +//! [`ChatEnumError`] — a clean, typed error — and NEVER panics, upholding dig-message's fail-cleanly +//! rule for anything read off the wire. + +use chia_protocol::Bytes32; +use chia_streamable_macro::Streamable; + +/// An unrecognized `status`/`state` discriminant read off the wire (SPEC §2). Surfaced as a clean +/// error — decoding a chat enum NEVER panics on an out-of-range byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChatEnumError { + /// The name of the enum that rejected the value (for a legible message). + pub enum_name: &'static str, + /// The out-of-range discriminant that was read. + pub value: u8, +} + +impl core::fmt::Display for ChatEnumError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "unrecognized {} discriminant {}", + self.enum_name, self.value + ) + } +} + +impl std::error::Error for ChatEnumError {} + +/// Declare a `u8`-wire enum with an exhaustive [`TryFrom`] that rejects unknown values cleanly. +/// +/// The wire always carries the raw `u8`; this enum is the typed, validated view of it. Adding a new +/// variant is additive (a new discriminant) and never renumbers an existing one (SPEC §2 additive-only). +macro_rules! wire_enum { + ( + $(#[$meta:meta])* + pub enum $name:ident { $( $(#[$vmeta:meta])* $variant:ident = $value:literal ),+ $(,)? } + ) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u8)] + pub enum $name { + $( $(#[$vmeta])* $variant = $value ),+ + } + + impl $name { + /// This variant's on-wire `u8` discriminant. + #[must_use] + pub fn as_u8(self) -> u8 { + self as u8 + } + } + + impl TryFrom for $name { + type Error = ChatEnumError; + + fn try_from(value: u8) -> Result { + match value { + $( $value => Ok(Self::$variant), )+ + other => Err(ChatEnumError { enum_name: stringify!($name), value: other }), + } + } + } + }; +} + +wire_enum! { + /// The delivery outcome carried in [`DeliveryReceipt::status`] (SPEC §2, id `0x0201`). + pub enum DeliveryStatus { + /// The message reached the recipient's node. + Delivered = 0, + /// Delivery failed (undeliverable / rejected). + Failed = 1, + } +} + +wire_enum! { + /// The typing transition carried in [`TypingIndicator::state`] (SPEC §2, id `0x0203`). + pub enum TypingState { + /// The peer began composing. + Started = 0, + /// The peer stopped composing. + Stopped = 1, + } +} + +wire_enum! { + /// The presence state carried in [`Presence::state`] (SPEC §2, id `0x0204`). + pub enum PresenceState { + /// The peer is online and reachable. + Online = 0, + /// The peer is idle / away. + Away = 1, + /// The peer is offline. + Offline = 2, + } +} + +/// A chat message (SPEC §2, id `0x0200`). The `envelope` is the OPAQUE `DIGCHAT1` content seal — the +/// sole content-bearing field, carried verbatim and never parsed by this layer (SPEC §1, §6). +#[derive(Debug, Clone, PartialEq, Eq, Streamable)] +pub struct ChatMessage { + /// The application-assigned message id that receipts (`0x0201`/`0x0202`) reference. + pub message_id: Bytes32, + /// The opaque `DIGCHAT1` sealed content blob (SPEC §4 of the dig-chat app spec). Never parsed here. + pub envelope: Vec, +} + +/// A delivery receipt (SPEC §2, id `0x0201`) acknowledging that a [`ChatMessage`] was (or was not) +/// delivered. `status` is a [`DeliveryStatus`] on the wire as a `u8`. +#[derive(Debug, Clone, PartialEq, Eq, Streamable)] +pub struct DeliveryReceipt { + /// The [`ChatMessage::message_id`] this receipt refers to. + pub message_id: Bytes32, + /// The delivery outcome as a [`DeliveryStatus`] discriminant. + pub status: u8, +} + +impl DeliveryReceipt { + /// The typed delivery outcome. + /// + /// # Errors + /// [`ChatEnumError`] if `status` is not a recognized [`DeliveryStatus`] discriminant. + pub fn status(&self) -> Result { + DeliveryStatus::try_from(self.status) + } +} + +/// A read receipt (SPEC §2, id `0x0202`) acknowledging that a [`ChatMessage`] was read. +#[derive(Debug, Clone, PartialEq, Eq, Streamable)] +pub struct ReadReceipt { + /// The [`ChatMessage::message_id`] that was read. + pub message_id: Bytes32, +} + +/// A typing indicator (SPEC §2, id `0x0203`) for a conversation. `state` is a [`TypingState`] on the +/// wire as a `u8`. +#[derive(Debug, Clone, PartialEq, Eq, Streamable)] +pub struct TypingIndicator { + /// The conversation this indicator applies to (a 1:1 conversation is correlation by peer DID). + pub conversation_id: Bytes32, + /// The typing transition as a [`TypingState`] discriminant. + pub state: u8, +} + +impl TypingIndicator { + /// The typed typing transition. + /// + /// # Errors + /// [`ChatEnumError`] if `state` is not a recognized [`TypingState`] discriminant. + pub fn state(&self) -> Result { + TypingState::try_from(self.state) + } +} + +/// A presence announcement (SPEC §2, id `0x0204`). `state` is a [`PresenceState`] on the wire as a `u8`. +#[derive(Debug, Clone, PartialEq, Eq, Streamable)] +pub struct Presence { + /// The presence state as a [`PresenceState`] discriminant. + pub state: u8, +} + +impl Presence { + /// The typed presence state. + /// + /// # Errors + /// [`ChatEnumError`] if `state` is not a recognized [`PresenceState`] discriminant. + pub fn state(&self) -> Result { + PresenceState::try_from(self.state) + } +} diff --git a/crates/dig-chat-protocol/tests/kat.rs b/crates/dig-chat-protocol/tests/kat.rs new file mode 100644 index 0000000..dda6a36 --- /dev/null +++ b/crates/dig-chat-protocol/tests/kat.rs @@ -0,0 +1,383 @@ +//! Known-Answer-Test (KAT) harness for dig-chat-protocol — the golden vectors that pin the byte-level +//! wire contract of the five chat payloads (SPEC §2, §3, §5) plus the registry behaviour (SPEC §4). +//! +//! Golden values are committed as SHA-256 digests of the deterministic on-wire bytes: a digest change +//! means the wire format drifted, which MUST be an intentional, reviewed SemVer event — never an +//! accident. ALL test material is DERIVED from a hashed seed (never a hard-coded literal — CodeQL). + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use chia_protocol::Bytes32; +use chia_traits::Streamable; +use dig_message::{InteractionShape, MessageBand, MessageRegistry, MessageType, Result}; +use sha2::{Digest, Sha256}; + +use dig_chat_protocol::{ + register_all, ChatHandler, ChatMessage, DeliveryReceipt, DeliveryStatus, Presence, + PresenceState, ReadReceipt, TypingIndicator, TypingState, CHAT_MESSAGE_TYPES, ID_CHAT_MESSAGE, + ID_DELIVERY_RECEIPT, ID_PRESENCE, ID_READ_RECEIPT, ID_TYPING_INDICATOR, +}; + +// ── Deterministic, seed-derived test material (never a hard-coded literal — CodeQL). ── + +/// SHA-256(tag ‖ counter) chained to `n` bytes — reproducible across runs and machines. +fn seeded(tag: &[u8], n: usize) -> Vec { + let mut out = Vec::new(); + let mut counter = 0u64; + while out.len() < n { + let mut hasher = Sha256::new(); + hasher.update(tag); + hasher.update(counter.to_le_bytes()); + out.extend_from_slice(&hasher.finalize()); + counter += 1; + } + out.truncate(n); + out +} + +fn b32(tag: &[u8]) -> Bytes32 { + Bytes32::new(seeded(tag, 32).try_into().unwrap()) +} + +/// Lowercase-hex SHA-256 of the on-wire bytes — the committed golden form. +fn digest(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +// ── Golden byte-vectors (SPEC §3 wire determinism). encode→bytes and bytes→decode round-trip. ── + +/// Assert that `value` encodes to exactly `len` bytes with SHA-256 `want`, and decodes back identically. +fn assert_golden(value: &T, len: usize, want: &str) +where + T: Streamable + PartialEq + std::fmt::Debug, +{ + let bytes = value.to_bytes().unwrap(); + assert_eq!(bytes.len(), len, "wire length drifted"); + assert_eq!( + digest(&bytes), + want, + "wire bytes drifted (byte-determinism regression)" + ); + let decoded = T::from_bytes(&bytes).unwrap(); + assert_eq!( + &decoded, value, + "decode did not round-trip byte-identically" + ); +} + +fn golden_chat_message() -> ChatMessage { + ChatMessage { + message_id: b32(b"chat-mid"), + envelope: seeded(b"digchat1-blob", 40), + } +} +fn golden_delivery_receipt() -> DeliveryReceipt { + DeliveryReceipt { + message_id: b32(b"deliv-mid"), + status: DeliveryStatus::Delivered.as_u8(), + } +} +fn golden_read_receipt() -> ReadReceipt { + ReadReceipt { + message_id: b32(b"read-mid"), + } +} +fn golden_typing() -> TypingIndicator { + TypingIndicator { + conversation_id: b32(b"typing-conv"), + state: TypingState::Started.as_u8(), + } +} +fn golden_presence() -> Presence { + Presence { + state: PresenceState::Away.as_u8(), + } +} + +#[test] +fn kat_chat_message() { + assert_golden( + &golden_chat_message(), + 76, + "1242949e1b33fe2c73f546f132c894d3a6a118499cbc1e0d24c54dbec306a984", + ); +} + +#[test] +fn kat_delivery_receipt() { + assert_golden( + &golden_delivery_receipt(), + 33, + "e3a3d3eebc8e5eeb2fb539366ad4f9c45ec7dcfa8b7735ac38a37476fc24bcf1", + ); +} + +#[test] +fn kat_read_receipt() { + assert_golden( + &golden_read_receipt(), + 32, + "5e79288de830117c1a6fa9ce8efad1ccb8ad3a699a2b71c1c74cb3c66fd79958", + ); +} + +#[test] +fn kat_typing_indicator() { + assert_golden( + &golden_typing(), + 33, + "2a9bb74d921dfcf1863eadd79dd2041f21a51985f2c27d3f14d8b700cae8f576", + ); +} + +#[test] +fn kat_presence() { + assert_golden( + &golden_presence(), + 1, + "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a", + ); +} + +#[test] +fn print_digests() { + for (name, bytes) in [ + ("chat", golden_chat_message().to_bytes().unwrap()), + ("delivery", golden_delivery_receipt().to_bytes().unwrap()), + ("read", golden_read_receipt().to_bytes().unwrap()), + ("typing", golden_typing().to_bytes().unwrap()), + ("presence", golden_presence().to_bytes().unwrap()), + ] { + eprintln!("DIGEST {name} len={} sha={}", bytes.len(), digest(&bytes)); + } +} + +// ── Band membership (SPEC §2, §4). ── + +#[test] +fn all_ids_are_in_the_dig_chat_band_and_contiguous() { + let ids = [ + ID_CHAT_MESSAGE, + ID_DELIVERY_RECEIPT, + ID_READ_RECEIPT, + ID_TYPING_INDICATOR, + ID_PRESENCE, + ]; + assert_eq!(ids, CHAT_MESSAGE_TYPES); + for (offset, id) in ids.iter().enumerate() { + assert_eq!(id.band(), MessageBand::DigChat, "{id:?} in dig-chat band"); + assert_eq!( + id.0, + 0x0000_0200 + offset as u32, + "ids are contiguous from 0x0200" + ); + } + assert_eq!(ID_PRESENCE.0, 0x0000_0204); +} + +// ── register_all → dispatch routing + duplicate refusal (SPEC §4). ── + +/// A handler that records which method fired via a per-type counter. +#[derive(Default)] +struct CountingHandler { + chat: AtomicU32, + delivery: AtomicU32, + read: AtomicU32, + typing: AtomicU32, + presence: AtomicU32, +} +impl ChatHandler for CountingHandler { + fn on_chat_message(&self, _m: ChatMessage) -> Result<()> { + self.chat.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn on_delivery_receipt(&self, _m: DeliveryReceipt) -> Result<()> { + self.delivery.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn on_read_receipt(&self, _m: ReadReceipt) -> Result<()> { + self.read.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn on_typing_indicator(&self, _m: TypingIndicator) -> Result<()> { + self.typing.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn on_presence(&self, _m: Presence) -> Result<()> { + self.presence.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn register_all_routes_each_id_to_the_right_decoded_kind() { + let handler = Arc::new(CountingHandler::default()); + let mut registry = MessageRegistry::new(); + register_all(&mut registry, handler.clone()).unwrap(); + assert_eq!(registry.len(), 5); + + let cases: [(MessageType, Vec); 5] = [ + (ID_CHAT_MESSAGE, golden_chat_message().to_bytes().unwrap()), + ( + ID_DELIVERY_RECEIPT, + golden_delivery_receipt().to_bytes().unwrap(), + ), + (ID_READ_RECEIPT, golden_read_receipt().to_bytes().unwrap()), + (ID_TYPING_INDICATOR, golden_typing().to_bytes().unwrap()), + (ID_PRESENCE, golden_presence().to_bytes().unwrap()), + ]; + for (id, payload) in &cases { + registry + .dispatch(*id, InteractionShape::OneShot, payload) + .unwrap(); + } + + assert_eq!(handler.chat.load(Ordering::SeqCst), 1); + assert_eq!(handler.delivery.load(Ordering::SeqCst), 1); + assert_eq!(handler.read.load(Ordering::SeqCst), 1); + assert_eq!(handler.typing.load(Ordering::SeqCst), 1); + assert_eq!(handler.presence.load(Ordering::SeqCst), 1); +} + +#[test] +fn register_all_refuses_duplicates() { + let mut registry = MessageRegistry::new(); + register_all(&mut registry, Arc::new(CountingHandler::default())).unwrap(); + // A second registration collides on the very first id. + let err = register_all(&mut registry, Arc::new(CountingHandler::default())).unwrap_err(); + assert_eq!( + err, + dig_message::MessageError::DuplicateType(ID_CHAT_MESSAGE.0) + ); +} + +#[test] +fn register_all_is_additive_over_a_pre_populated_registry() { + // Pre-register an unrelated peer-RPC-band handler, then add the chat layer additively. + let mut registry = MessageRegistry::new(); + struct Ping; + #[derive(chia_streamable_macro::Streamable, Debug, PartialEq, Eq)] + struct PingPayload { + nonce: u64, + } + impl dig_message::MessageKind for Ping { + const TYPE_ID: MessageType = MessageType(dig_message::BAND_PEER_RPC); + type Payload = PingPayload; + } + registry + .register::(|_: PingPayload| Ok(())) + .unwrap(); + + register_all(&mut registry, Arc::new(CountingHandler::default())).unwrap(); + assert_eq!(registry.len(), 6, "the pre-existing handler is undisturbed"); + assert!(registry.contains(MessageType(dig_message::BAND_PEER_RPC))); +} + +// ── Unknown in-band id follows dig-message's unknown-type rule; never panics (SPEC §4). ── + +#[test] +fn unknown_in_band_id_follows_the_unknown_type_rule() { + let mut registry = MessageRegistry::new(); + register_all(&mut registry, Arc::new(CountingHandler::default())).unwrap(); + let unknown = MessageType(0x0000_02FF); // in the dig-chat band, but unallocated. + assert_eq!(unknown.band(), MessageBand::DigChat); + + // A one-shot unknown is silently dropped (no panic, no error). + assert_eq!( + registry + .dispatch(unknown, InteractionShape::OneShot, &[]) + .unwrap(), + dig_message::Dispatch::Dropped + ); + // A request-shaped unknown surfaces UNSUPPORTED_TYPE (no panic). + assert_eq!( + registry + .dispatch(unknown, InteractionShape::Request, &[]) + .unwrap_err(), + dig_message::MessageError::UnsupportedType(unknown.0) + ); +} + +// ── DIGCHAT1 passthrough: arbitrary opaque envelope round-trips byte-identically (SPEC §1, §6). ── + +#[test] +fn chat_message_round_trips_arbitrary_opaque_envelopes() { + let envelopes: Vec> = vec![ + Vec::new(), // empty + vec![0u8], // single byte + seeded(b"random-blob", 1), // tiny + seeded(b"random-blob-2", 4096), // large + seeded(b"random-blob-3", 65_537), // > 64 KiB + ]; + for env in envelopes { + let msg = ChatMessage { + message_id: b32(b"pt-mid"), + envelope: env.clone(), + }; + let bytes = msg.to_bytes().unwrap(); + let decoded = ChatMessage::from_bytes(&bytes).unwrap(); + assert_eq!( + decoded.envelope, env, + "opaque envelope must survive verbatim" + ); + assert_eq!(decoded, msg); + } +} + +// ── Enum reject: an out-of-range discriminant decodes to a clean error, never a panic (SPEC §2). ── + +#[test] +fn out_of_range_enum_discriminants_reject_cleanly() { + // The struct still decodes (the wire is a plain u8); the typed accessor rejects the value. + let bad_delivery = DeliveryReceipt { + message_id: b32(b"x"), + status: 200, + }; + let bytes = bad_delivery.to_bytes().unwrap(); + let decoded = DeliveryReceipt::from_bytes(&bytes).unwrap(); + let err = decoded.status().unwrap_err(); + assert_eq!(err.value, 200); + assert!(err.to_string().contains("DeliveryStatus")); + + assert!(TypingIndicator { + conversation_id: b32(b"x"), + state: 9 + } + .state() + .is_err()); + assert!(Presence { state: 250 }.state().is_err()); + + // The valid discriminants decode to their variants. + assert_eq!(DeliveryStatus::try_from(1).unwrap(), DeliveryStatus::Failed); + assert_eq!(TypingState::try_from(1).unwrap(), TypingState::Stopped); + assert_eq!(PresenceState::try_from(2).unwrap(), PresenceState::Offline); + assert_eq!( + DeliveryReceipt { + message_id: b32(b"y"), + status: 0 + } + .status() + .unwrap(), + DeliveryStatus::Delivered + ); + assert_eq!( + TypingIndicator { + conversation_id: b32(b"y"), + state: 0 + } + .state() + .unwrap(), + TypingState::Started + ); + assert_eq!( + Presence { state: 0 }.state().unwrap(), + PresenceState::Online + ); +} diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 780ae85..1cda30b 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -68,6 +68,9 @@ digstore-stage = { git = "https://github.com/DIG-Network/digstore.git", rev = "4 digstore-crypto = { git = "https://github.com/DIG-Network/digstore.git", rev = "4c34f0be" } digstore-compiler = { git = "https://github.com/DIG-Network/digstore.git", rev = "4c34f0be" } chia-protocol = "0.26" +# The `Streamable` to_bytes/from_bytes trait, used to serialize the chat `ChatMessage` payload before +# the dig-message seal. Same 0.26 as chia-protocol so the byte layout agrees across the workspace. +chia-traits = "0.26" # -- Seam 1: Chia light-client CONFIRMATION/PEAK observability (#1314) -------------------------------- # `chia-peer` is a subscribing Chia wallet-protocol light client (a thin driver over chia-wallet-sdk # 0.30 / chia-protocol 0.26): it tracks the peak + coin/puzzle-hash confirmations for the coins the @@ -105,6 +108,13 @@ serde_json = "1" # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). dig-rpc-protocol = "0.6" +# The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope +# the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 +# envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. +dig-message = "0.5" +# The chat message-TYPE layer: the five chat payloads on dig-message's dig-chat band + the registry +# wiring. A crypto-free, content-blind type layer — the DIGCHAT1 seal is carried as opaque bytes. +dig-chat-protocol = { path = "../dig-chat-protocol", version = "0.1" } base64 = "0.22" hex = "0.4" # The whole-module pull's one content-addressing primitive (#1576): the module descriptor's per-chunk + diff --git a/crates/dig-node-core/src/chat.rs b/crates/dig-node-core/src/chat.rs new file mode 100644 index 0000000..be91859 --- /dev/null +++ b/crates/dig-node-core/src/chat.rs @@ -0,0 +1,644 @@ +//! The dig-node chat subsystem — the TRANSPORT half of dig-chat (epic #793, Lane B). +//! +//! dig-node is the courier, never the correspondent. An application (the DIG Browser chat UI, a CLI) +//! hands the node an already-sealed **opaque `DIGCHAT1` envelope** plus the recipient it is for; the +//! node wraps that opaque blob in a [`dig_message`] envelope sealed to the recipient's `0x0010` BLS +//! identity key and hands the sealed bytes to [`dig_gossip`] for a directed peer send. Inbound, the +//! node opens the [`dig_message`] envelope, routes it through the chat [`MessageRegistry`], and queues +//! the decoded [`ChatMessage`] into a per-node inbox that the `chat.poll` RPC drains. +//! +//! ## The double seal (NC-1 — content-blindness) +//! Two independent seals stack, so no intermediary ever sees plaintext: +//! 1. the **inner** `DIGCHAT1` seal the app applies to the message body (this layer never parses it — +//! [`ChatMessage::envelope`] carries it verbatim); +//! 2. the **outer** [`dig_message`] e2e seal to the recipient's BLS identity key, which is what +//! dig-gossip actually carries over opcode 220. +//! A relay or on-path peer sees only the outer ciphertext; even a peer that terminates the outer seal +//! would still face the inner `DIGCHAT1` seal. The node is content-blind by construction. +//! +//! ## What is deliberately NOT here (MVP scope, epic #793) +//! - **Sealing-key resolution (`resolveSealingKey`).** Mapping a recipient DID to its attested `0x0010` +//! BLS sealing key + its gossip [`PeerId`] is the deferred key directory; for the MVP the calling app +//! supplies both (see `chat.send` params). Inbound sender-key resolution is likewise a caller-supplied +//! [`SenderKeyResolver`]; until the directory lands an unresolvable sender is dropped, never trusted. +//! - Group chat, onion routing, and receipt UX. The five types + directed send/receive only. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use chia_protocol::Bytes32; +use dig_chat_protocol::{ + register_all, ChatHandler, ChatMessage, DeliveryReceipt, Presence, ReadReceipt, + TypingIndicator, ID_CHAT_MESSAGE, +}; +use dig_message::{ + decode_envelope, open_message, seal_message, InteractionShape, MessageRegistry, MessageType, + ReplayGuard, SealParams, +}; +use dig_tls::bls::{public_key_bytes, SecretKey}; +use sha2::{Digest, Sha256}; + +/// The dig-message freshness window is 5 minutes; a sealed chat message expires one window after it is +/// sent so a captured envelope cannot be replayed indefinitely (dig-message §5.6b). +const CHAT_TTL_MS: u64 = 300_000; + +/// Resolves a message sender's `(DID, key epoch)` to its 48-byte BLS G1 identity public key, so an +/// inbound envelope's signature + auth-decap can be verified. Returns `None` for an unknown sender +/// (the message is then dropped, never trusted). The production implementation is the deferred key +/// directory; the MVP passes a caller-supplied closure. +pub type SenderKeyResolver<'a> = dyn Fn(Bytes32, u32) -> Option<[u8; 48]> + 'a; + +/// A decoded inbound chat message queued for the paired application to poll. +/// +/// It carries only what the app needs to render + correlate: who sent it (the verified envelope +/// sender DID), the application message id, and the still-opaque `DIGCHAT1` body. The body is never +/// parsed by the node. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InboundChat { + /// The verified sender DID launcher id from the opened [`dig_message`] envelope. + pub sender_did: Bytes32, + /// The application message id from the [`ChatMessage`]. + pub message_id: Bytes32, + /// The opaque `DIGCHAT1` content seal, carried verbatim — never parsed by the node. + pub envelope: Vec, +} + +/// A bounded FIFO of decoded inbound chat messages awaiting a `chat.poll`. +/// +/// The subsystem pushes as directed messages arrive; `chat.poll` drains. Bounded so a peer that spams +/// a node it is paired with cannot grow memory without limit — the oldest queued message is dropped +/// once [`ChatInbox::CAPACITY`] is reached (the paired app is expected to poll promptly). +#[derive(Debug, Default)] +pub struct ChatInbox { + queue: Mutex>, +} + +impl ChatInbox { + /// The most inbound messages held before the oldest is dropped to bound memory. + pub const CAPACITY: usize = 4096; + + /// An empty inbox. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Queue one decoded inbound message, evicting the oldest if the inbox is at capacity. + pub fn push(&self, message: InboundChat) { + let mut queue = self.queue.lock().expect("chat inbox mutex poisoned"); + if queue.len() >= Self::CAPACITY { + queue.pop_front(); + } + queue.push_back(message); + } + + /// Remove and return every queued message in arrival order, leaving the inbox empty. + pub fn drain(&self) -> Vec { + let mut queue = self.queue.lock().expect("chat inbox mutex poisoned"); + queue.drain(..).collect() + } + + /// The number of messages currently queued. + #[must_use] + pub fn len(&self) -> usize { + self.queue.lock().expect("chat inbox mutex poisoned").len() + } + + /// Whether no messages are queued. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// The per-node chat state: the inbound message inbox plus the monotonic anti-replay send counter. +/// +/// One value lives on the [`crate::Node`]; the send counter is strictly increasing across the process +/// so each sealed message a node emits carries a fresh counter (dig-message §5.6 anti-replay). +#[derive(Debug)] +pub struct ChatState { + /// Decoded inbound messages awaiting `chat.poll`. + pub inbox: Arc, + /// The strictly-monotonic per-node anti-replay counter, seeded from wall-clock ms so it keeps + /// increasing across restarts. + send_counter: AtomicU64, +} + +impl Default for ChatState { + fn default() -> Self { + Self::new() + } +} + +impl ChatState { + /// Fresh chat state with an empty inbox and a time-seeded send counter. + #[must_use] + pub fn new() -> Self { + Self { + inbox: Arc::new(ChatInbox::new()), + send_counter: AtomicU64::new(now_ms()), + } + } + + /// The next strictly-greater anti-replay counter for an outbound message. + fn next_counter(&self) -> u64 { + self.send_counter.fetch_add(1, Ordering::Relaxed) + 1 + } +} + +/// Current wall-clock time in Unix milliseconds (the dig-message freshness clock). +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Derive a deterministic application message id for an outbound chat message. +/// +/// `SHA-256(sender_did ‖ counter ‖ opaque_envelope)` — a content-derived id (never an integer literal +/// nonce, CodeQL) that is unique per (sender, counter) and stable for a given body. +fn derive_message_id(sender_did: Bytes32, counter: u64, envelope: &[u8]) -> Bytes32 { + let mut hasher = Sha256::new(); + hasher.update(sender_did.as_ref()); + hasher.update(counter.to_be_bytes()); + hasher.update(envelope); + let digest: [u8; 32] = hasher.finalize().into(); + Bytes32::from(digest) +} + +/// The DID a node seals as when it originates a chat message: `SHA-256(node BLS G1 public key)`. +/// +/// The node's `0x0010` identity is a BLS keypair, not a DID-anchored singleton, so for the MVP the +/// sender DID is derived deterministically from that public key. A recipient's [`SenderKeyResolver`] +/// resolves this DID back to the same public key. (A real DID launcher id supersedes this once the key +/// directory lands — the deferred `resolveSealingKey` work.) +#[must_use] +pub fn node_sender_did(node_sk: &SecretKey) -> Bytes32 { + let mut hasher = Sha256::new(); + hasher.update(public_key_bytes(node_sk)); + let digest: [u8; 32] = hasher.finalize().into(); + Bytes32::from(digest) +} + +/// The sealed outbound bytes plus the id the node minted for the message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SealedChat { + /// The application message id `chat.send` returns to the caller. + pub message_id: Bytes32, + /// The dig-message-sealed envelope bytes handed to dig-gossip's directed send. + pub sealed: Vec, +} + +/// Seal an app-supplied opaque `DIGCHAT1` envelope into a directed [`dig_message`] envelope addressed +/// to `recipient_pub` (the recipient's `0x0010` BLS identity key), sent as the node identity. +/// +/// The returned [`SealedChat::sealed`] bytes are the opaque payload dig-gossip carries over opcode 220 +/// — a peer sees only this outer ciphertext (NC-1). This function never inspects `opaque_envelope`. +/// +/// # Errors +/// A stringified [`dig_message::MessageError`] if `recipient_pub` fails its subgroup check or the seal +/// fails. +pub fn seal_outbound( + node_sk: &SecretKey, + recipient_did: Bytes32, + recipient_pub: &[u8; 48], + opaque_envelope: &[u8], + counter: u64, +) -> Result { + let sender_did = node_sender_did(node_sk); + let message_id = derive_message_id(sender_did, counter, opaque_envelope); + + // The typed payload the recipient decodes: the app message id + the opaque DIGCHAT1 body. This + // whole struct is compressed + sealed by dig-message below, so the message id + body are ciphertext + // on the wire. + let payload = encode_chat_message(&ChatMessage { + message_id, + envelope: opaque_envelope.to_vec(), + })?; + + let now = now_ms(); + let envelope = seal_message(&SealParams { + sender_sk: node_sk, + sender: sender_did, + sender_epoch: 0, + recipient: recipient_did, + recipient_pub, + message_type: ID_CHAT_MESSAGE.0, + shape: InteractionShape::OneShot, + correlation_id: message_id, + stream: None, + counter, + timestamp_ms: now, + expires_at: now + CHAT_TTL_MS, + payload: &payload, + }) + .map_err(|e| format!("seal chat message: {e}"))?; + + let sealed = dig_message::encode_envelope(&envelope) + .map_err(|e| format!("encode chat envelope: {e}"))?; + Ok(SealedChat { message_id, sealed }) +} + +/// Open an inbound directed [`dig_message`] envelope, route it through the chat registry, and queue +/// any decoded [`ChatMessage`] into `inbox`. +/// +/// `resolve_sender` maps the envelope's cleartext `(sender DID, epoch)` to the sender's BLS G1 key so +/// the signature + auth-decap verify; an unresolvable sender is rejected (never trusted). Receipts, +/// typing, and presence types are recognised + decoded (so an unknown in-band id still fails cleanly) +/// but are not surfaced to the poll inbox in the MVP. +/// +/// # Errors +/// A stringified [`dig_message::MessageError`] if the envelope fails to decode, the sender is +/// unresolvable, or the seal/signature/replay/expiry checks fail. A well-formed envelope from an +/// untrusted sender is an error, not a queued message — fail closed. +pub fn open_into_inbox( + recipient_sk: &SecretKey, + sealed: &[u8], + resolve_sender: &SenderKeyResolver<'_>, + guard: &mut ReplayGuard, + inbox: &Arc, +) -> Result<(), String> { + let envelope = decode_envelope(sealed).map_err(|e| format!("decode chat envelope: {e}"))?; + let opened = open_message(recipient_sk, &envelope, resolve_sender, guard, now_ms()) + .map_err(|e| format!("open chat envelope: {e}"))?; + + // Route the decoded payload through the chat type registry, capturing the verified sender so the + // ChatMessage handler can stamp it onto the queued record. + let mut registry = MessageRegistry::new(); + let handler = Arc::new(InboxHandler { + inbox: Arc::clone(inbox), + sender_did: opened.sender, + }); + register_all(&mut registry, handler).map_err(|e| format!("register chat types: {e}"))?; + registry + .dispatch( + MessageType(opened.message_type), + opened.shape, + &opened.payload, + ) + .map_err(|e| format!("dispatch chat message: {e}"))?; + Ok(()) +} + +/// Process one inbound directed frame the peer network delivered, queuing a decoded chat message. +/// +/// The peer-network inbound loop calls this for every `(PeerId, Message)` it receives: a non-opcode-220 +/// frame is ignored (returns `Ok(false)`); an opcode-220 frame is opened + dispatched into `inbox` +/// (returns `Ok(true)` on a queued message). `resolve_sender` is the sender-key directory +/// (`resolveSealingKey`, deferred — epic #793); until it can resolve a sender, its frames are rejected +/// rather than trusted. +/// +/// # Errors +/// A stringified error if an opcode-220 frame fails to open/verify/dispatch (a malformed, untrusted, +/// replayed, or expired envelope) — the transport logs it and moves on; it is never a panic. +pub fn process_inbound_frame( + recipient_sk: &SecretKey, + msg_type: u8, + data: &[u8], + resolve_sender: &SenderKeyResolver<'_>, + guard: &mut ReplayGuard, + inbox: &Arc, +) -> Result { + if !dig_gossip::is_dig_message(msg_type) { + return Ok(false); + } + let before = inbox.len(); + open_into_inbox(recipient_sk, data, resolve_sender, guard, inbox)?; + Ok(inbox.len() > before) +} + +/// The [`ChatHandler`] that queues a decoded [`ChatMessage`] into the node inbox, stamping it with the +/// verified envelope sender. The non-message chat types are decoded (proving they are well-formed) but +/// dropped in the MVP — only messages surface to `chat.poll`. +struct InboxHandler { + inbox: Arc, + sender_did: Bytes32, +} + +impl ChatHandler for InboxHandler { + fn on_chat_message(&self, message: ChatMessage) -> dig_message::Result<()> { + self.inbox.push(InboundChat { + sender_did: self.sender_did, + message_id: message.message_id, + envelope: message.envelope, + }); + Ok(()) + } + + fn on_delivery_receipt(&self, _receipt: DeliveryReceipt) -> dig_message::Result<()> { + Ok(()) + } + + fn on_read_receipt(&self, _receipt: ReadReceipt) -> dig_message::Result<()> { + Ok(()) + } + + fn on_typing_indicator(&self, _indicator: TypingIndicator) -> dig_message::Result<()> { + Ok(()) + } + + fn on_presence(&self, _presence: Presence) -> dig_message::Result<()> { + Ok(()) + } +} + +/// Serialize a [`ChatMessage`] to its byte-deterministic Streamable payload. +fn encode_chat_message(message: &ChatMessage) -> Result, String> { + use chia_traits::Streamable as _; + message + .to_bytes() + .map_err(|e| format!("encode chat message payload: {e}")) +} + +/// Mint the next outbound anti-replay counter for `state` — the seam the RPC layer uses so tests can +/// drive [`seal_outbound`] with an explicit counter while production stays monotonic. +#[must_use] +pub fn next_send_counter(state: &ChatState) -> u64 { + state.next_counter() +} + +// ── The RPC surface on the node (dispatched from `seams::dig_rpc`) ────────────────────────────── + +use serde_json::{json, Value}; + +/// JSON-RPC error codes for the chat surface (in the node's private application range). +mod rpc_code { + /// The node has no persistent identity key, so it cannot seal as a sender. + pub const NO_IDENTITY: i64 = -32050; + /// A required parameter was missing or malformed. + pub const BAD_PARAMS: i64 = -32602; + /// The peer network is not up (no gossip pool), so a directed send has no transport. + pub const NO_PEER_NETWORK: i64 = -32051; + /// The seal or the directed send failed. + pub const SEND_FAILED: i64 = -32052; +} + +/// Decode a required 64-hex (optionally `0x`-prefixed) 32-byte parameter. +fn param_bytes32(params: &Value, key: &str) -> Result { + let hex_str = params + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("params.{key} (64-hex) is required"))?; + let raw = hex::decode(hex_str.trim_start_matches("0x")) + .map_err(|_| format!("params.{key} must be hex"))?; + Bytes32::try_from(raw).map_err(|_| format!("params.{key} must be 32 bytes (64-hex)")) +} + +/// Decode a required base64 parameter into bytes. +fn param_b64(params: &Value, key: &str) -> Result, String> { + use base64::Engine as _; + let s = params + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| format!("params.{key} (base64) is required"))?; + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|_| format!("params.{key} must be valid base64")) +} + +impl crate::Node { + /// Handle `chat.send` — seal an app-supplied opaque `DIGCHAT1` envelope to the recipient and send + /// it to the recipient peer over dig-gossip's directed opcode-220 transport. + /// + /// Params: `{ recipient_did (64-hex), recipient_pub (base64, 48-byte BLS G1 sealing key), + /// peer_id (64-hex gossip target), envelope (base64, opaque DIGCHAT1 bytes) }`. `recipient_pub` + + /// `peer_id` are app-supplied for the MVP — the DID→key/peer directory (`resolveSealingKey`) is the + /// deferred key directory (epic #793). Result: `{ message_id (64-hex) }`. + pub async fn chat_send(&self, params: &Value, id: Value) -> Value { + let Some(seed) = self.identity_seed else { + return chat_err( + &id, + rpc_code::NO_IDENTITY, + "node has no identity key to seal as", + ); + }; + let recipient_did = match param_bytes32(params, "recipient_did") { + Ok(v) => v, + Err(e) => return chat_err(&id, rpc_code::BAD_PARAMS, &e), + }; + let peer_id = match param_bytes32(params, "peer_id") { + Ok(v) => v, + Err(e) => return chat_err(&id, rpc_code::BAD_PARAMS, &e), + }; + let recipient_pub: [u8; 48] = match param_b64(params, "recipient_pub") { + Ok(v) => match v.try_into() { + Ok(a) => a, + Err(_) => { + return chat_err(&id, rpc_code::BAD_PARAMS, "recipient_pub must be 48 bytes") + } + }, + Err(e) => return chat_err(&id, rpc_code::BAD_PARAMS, &e), + }; + let opaque = match param_b64(params, "envelope") { + Ok(v) => v, + Err(e) => return chat_err(&id, rpc_code::BAD_PARAMS, &e), + }; + + let node_sk = SecretKey::from_seed(&seed); + let counter = self.chat.next_counter(); + let sealed = match seal_outbound(&node_sk, recipient_did, &recipient_pub, &opaque, counter) + { + Ok(s) => s, + Err(e) => return chat_err(&id, rpc_code::SEND_FAILED, &e), + }; + + let Some(gossip) = self.gossip.get() else { + return chat_err( + &id, + rpc_code::NO_PEER_NETWORK, + "no peer network to send over", + ); + }; + match gossip + .send_dig_message(dig_gossip::PeerId::from(peer_id), &sealed.sealed, None) + .await + { + Ok(()) => json!({"jsonrpc":"2.0","id":id, + "result":{"message_id": hex::encode(sealed.message_id.as_ref())}}), + Err(e) => chat_err( + &id, + rpc_code::SEND_FAILED, + &format!("directed send failed: {e}"), + ), + } + } + + /// Handle `chat.poll` — drain and return every inbound chat message the node has queued since the + /// last poll (the MVP delivery surface, mirroring the node's other pull-style control reads). + /// + /// Result: `{ messages: [{ sender_did, message_id, envelope (base64 opaque DIGCHAT1) }] }`. + pub fn chat_poll(&self, id: Value) -> Value { + use base64::Engine as _; + let messages: Vec = self + .chat + .inbox + .drain() + .into_iter() + .map(|m| { + json!({ + "sender_did": hex::encode(m.sender_did.as_ref()), + "message_id": hex::encode(m.message_id.as_ref()), + "envelope": base64::engine::general_purpose::STANDARD.encode(&m.envelope), + }) + }) + .collect(); + json!({"jsonrpc":"2.0","id":id,"result":{"messages": messages}}) + } + + /// The chat inbox handle, so the peer-network bring-up can feed inbound opcode-220 frames into it. + #[must_use] + pub fn chat_inbox(&self) -> Arc { + Arc::clone(&self.chat.inbox) + } +} + +/// Build a chat JSON-RPC error response. +fn chat_err(id: &Value, code: i64, message: &str) -> Value { + json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}}) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A deterministic BLS key from a hashed label — never a hard-coded literal (CodeQL). + fn key(label: &str) -> SecretKey { + let mut hasher = Sha256::new(); + hasher.update(b"dig-chat-test-key"); + hasher.update(label.as_bytes()); + let seed: [u8; 32] = hasher.finalize().into(); + SecretKey::from_seed(&seed) + } + + /// A hashed-seed opaque DIGCHAT1 body of `n` bytes — stands in for the app's inner seal. + fn opaque(tag: &str, n: usize) -> Vec { + let mut out = Vec::new(); + let mut counter = 0u64; + while out.len() < n { + let mut hasher = Sha256::new(); + hasher.update(tag.as_bytes()); + hasher.update(counter.to_be_bytes()); + out.extend_from_slice(&hasher.finalize()); + counter += 1; + } + out.truncate(n); + out + } + + /// The seal→send→receive round-trip delivers the EXACT opaque envelope bytes to the inbox. + #[test] + fn send_receive_round_trip_preserves_opaque_envelope() { + let sender = key("sender"); + let recipient = key("recipient"); + let recipient_pub = public_key_bytes(&recipient); + let recipient_did = node_sender_did(&recipient); + let body = opaque("hello", 512); + + let sealed = seal_outbound(&sender, recipient_did, &recipient_pub, &body, 1).expect("seal"); + + let sender_pub = public_key_bytes(&sender); + let resolver = move |_did: Bytes32, _epoch: u32| Some(sender_pub); + let inbox = Arc::new(ChatInbox::new()); + let mut guard = ReplayGuard::new(); + open_into_inbox(&recipient, &sealed.sealed, &resolver, &mut guard, &inbox).expect("open"); + + let received = inbox.drain(); + assert_eq!(received.len(), 1); + assert_eq!( + received[0].envelope, body, + "opaque body must round-trip byte-identically" + ); + assert_eq!(received[0].message_id, sealed.message_id); + assert_eq!(received[0].sender_did, node_sender_did(&sender)); + } + + /// NC-1: neither the opaque DIGCHAT1 body nor the plaintext message id appears in the on-wire + /// sealed bytes — a relay/peer sees only ciphertext (the double seal). + #[test] + fn on_wire_bytes_are_ciphertext_only() { + let sender = key("sender"); + let recipient = key("recipient"); + let recipient_pub = public_key_bytes(&recipient); + let recipient_did = node_sender_did(&recipient); + // A distinctive body so a substring search is meaningful. + let body = opaque("secret-marker", 256); + + let sealed = seal_outbound(&sender, recipient_did, &recipient_pub, &body, 7).expect("seal"); + + assert!( + !contains(&sealed.sealed, &body), + "the opaque DIGCHAT1 body must NOT appear in the sealed on-wire bytes" + ); + assert!( + !contains(&sealed.sealed, sealed.message_id.as_ref()), + "the plaintext message id must NOT appear in the sealed on-wire bytes" + ); + } + + /// An envelope from a sender the recipient cannot resolve is rejected, never queued. + #[test] + fn unresolvable_sender_is_rejected() { + let sender = key("sender"); + let recipient = key("recipient"); + let recipient_pub = public_key_bytes(&recipient); + let recipient_did = node_sender_did(&recipient); + let sealed = seal_outbound(&sender, recipient_did, &recipient_pub, &opaque("x", 64), 1) + .expect("seal"); + + let resolver = |_did: Bytes32, _epoch: u32| None; + let inbox = Arc::new(ChatInbox::new()); + let mut guard = ReplayGuard::new(); + let result = open_into_inbox(&recipient, &sealed.sealed, &resolver, &mut guard, &inbox); + assert!(result.is_err()); + assert!(inbox.is_empty()); + } + + /// Malformed sealed bytes fail cleanly (no panic). + #[test] + fn malformed_envelope_fails_cleanly() { + let recipient = key("recipient"); + let resolver = |_did: Bytes32, _epoch: u32| Some([0u8; 48]); + let inbox = Arc::new(ChatInbox::new()); + let mut guard = ReplayGuard::new(); + let result = open_into_inbox( + &recipient, + b"not an envelope", + &resolver, + &mut guard, + &inbox, + ); + assert!(result.is_err()); + assert!(inbox.is_empty()); + } + + /// The inbox evicts the oldest message once it is full, bounding memory. + #[test] + fn inbox_is_bounded() { + let inbox = ChatInbox::new(); + for i in 0..(ChatInbox::CAPACITY + 10) { + inbox.push(InboundChat { + sender_did: Bytes32::from([1u8; 32]), + message_id: Bytes32::from([(i % 256) as u8; 32]), + envelope: vec![], + }); + } + assert_eq!(inbox.len(), ChatInbox::CAPACITY); + } + + /// The per-node send counter is strictly monotonic. + #[test] + fn send_counter_is_monotonic() { + let state = ChatState::new(); + let a = next_send_counter(&state); + let b = next_send_counter(&state); + let c = next_send_counter(&state); + assert!(a < b && b < c); + } + + /// Whether `haystack` contains `needle` as a contiguous byte substring. + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) + } +} diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 7c06c1d..b3dad00 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -47,6 +47,7 @@ use tokio::sync::Mutex; mod capsule_key; pub mod chainwatch; +pub mod chat; pub mod download; pub mod peer; /// The 7 architecturally-separated seams (#1285/#1303), populated incrementally across the @@ -344,6 +345,11 @@ pub struct Node { /// badge + proof-inspection modal. Populated on the existing verify step (never re-verified), /// fail-closed unchanged. See [`verification_ledger::VerificationLedger`]. verification_ledger: verification_ledger::VerificationLedger, + /// The chat subsystem state (epic #793): the inbound-message inbox `chat.poll` drains and the + /// monotonic anti-replay counter each outbound `chat.send` stamps. The node is the chat TRANSPORT + /// only — it seals an app-supplied opaque `DIGCHAT1` envelope and dig-gossip directed-sends it; it + /// never parses chat content. See [`chat`]. + chat: chat::ChatState, } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -2463,6 +2469,7 @@ impl Node { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::from_env(), + chat: chat::ChatState::new(), }) } @@ -2558,6 +2565,7 @@ pub(crate) mod test_support { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::new(0), + chat: chat::ChatState::new(), }; (Arc::new(node), td) } @@ -2912,6 +2920,7 @@ mod tests { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::new(0), + chat: chat::ChatState::new(), }; (node, td) } @@ -2989,6 +2998,7 @@ mod tests { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::new(0), + chat: chat::ChatState::new(), }; // Missing before the pull. @@ -3034,6 +3044,7 @@ mod tests { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::new(0), + chat: chat::ChatState::new(), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -3120,6 +3131,7 @@ mod tests { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::new(0), + chat: chat::ChatState::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -3188,6 +3200,7 @@ mod tests { self_ref: OnceLock::new(), gossip: OnceLock::new(), outgoing_throttle: bandwidth::OutgoingThrottle::new(0), + chat: chat::ChatState::new(), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -7636,6 +7649,7 @@ mod tests { seed_local_resource(&node, store, tip, rk, 5000); let node = Node { outgoing_throttle: bandwidth::OutgoingThrottle::new(10), + chat: chat::ChatState::new(), ..node }; // A holder for this EXACT content is known via the DHT. @@ -7682,6 +7696,7 @@ mod tests { seed_local_resource(&node, store, tip, rk, 5000); let node = Node { outgoing_throttle: bandwidth::OutgoingThrottle::new(10), + chat: chat::ChatState::new(), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -7727,6 +7742,7 @@ mod tests { seed_local_resource(&node, store, tip, rk, 5000); let node = Node { outgoing_throttle: bandwidth::OutgoingThrottle::new(10), + chat: chat::ChatState::new(), ..node }; @@ -7754,6 +7770,7 @@ mod tests { seed_local_resource(&node, store, tip, rk, 5000); let node = Node { outgoing_throttle: bandwidth::OutgoingThrottle::new(1_000_000), + chat: chat::ChatState::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -7790,6 +7807,7 @@ mod tests { seed_local_resource(&node, store, tip, rk, 5000); let node = Node { outgoing_throttle: bandwidth::OutgoingThrottle::new(10), + chat: chat::ChatState::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -7828,6 +7846,7 @@ mod tests { seed_local_resource(&node, store, tip, rk, 5000); let node = Node { outgoing_throttle: bandwidth::OutgoingThrottle::new(10), + chat: chat::ChatState::new(), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 74b1ed8..93e3b26 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -46,6 +46,20 @@ impl RpcDispatch for Node { let node = self; let id = req.get("id").cloned().unwrap_or(json!(1)); let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + // Chat subsystem (epic #793). These methods are NOT yet in the shared `dig-rpc-protocol` + // Method catalogue (promoting them there is a release-first follow-up), so they are dispatched + // here BEFORE the `Method::from_name` match. They are `served: "local"` in the shell's method + // catalogue, so the OpenRPC drift guard dispatches them through this path and never sees -32601. + match method { + "chat.send" => { + let params = req.get("params").cloned().unwrap_or(json!({})); + return node.chat_send(¶ms, id).await; + } + "chat.poll" => return node.chat_poll(id), + _ => {} + } + use dig_rpc_protocol::Method; // Dispatch on the canonical Method enum (dig-rpc-protocol, #1075) instead of // string literals, so the served method names cannot drift from the shared diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 60288c2..17f7262 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -471,6 +471,28 @@ pub fn methods() -> &'static [MethodInfo] { of control.peers.connect). Idempotent. Params { peer }.", requires_auth: true, }, + // -- chat subsystem (epic #793) — the directed-message TRANSPORT. Served LOCALLY by the + // node engine (dispatched in seams::dig_rpc BEFORE the Method catalogue). The node seals an + // app-supplied opaque DIGCHAT1 envelope to the recipient's 0x0010 key and dig-gossip + // directed-sends it; it never parses chat content. ------------------------------------- + MethodInfo { + name: "chat.send", + served: "local", + summary: "Seal an opaque DIGCHAT1 envelope to a recipient and directed-send it over \ + dig-gossip (opcode 220). Params { recipient_did (64-hex), recipient_pub \ + (base64 48-byte BLS G1 sealing key), peer_id (64-hex gossip target), \ + envelope (base64 opaque DIGCHAT1) }; result { message_id (64-hex) }. \ + recipient_pub + peer_id are app-supplied pending the key directory.", + requires_auth: false, + }, + MethodInfo { + name: "chat.poll", + served: "local", + summary: "Drain the node's inbound chat inbox. No params; result { messages: \ + [{ sender_did (64-hex), message_id (64-hex), envelope (base64 opaque \ + DIGCHAT1) }] } in arrival order.", + requires_auth: false, + }, ] } From 5d029b4a8805ff4272e8abdf0f99a6ab950682c1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 04:42:39 +0000 Subject: [PATCH 3/4] fix(chat): don't leak message_id in the cleartext correlation_id (NC-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit correlation_id is a CLEARTEXT dig-message envelope header; carrying the message_id there would expose it on the wire. Zero it for one-shot chat messages — the id travels sealed inside the ChatMessage payload. Refs #793 Co-Authored-By: Claude --- crates/dig-node-core/src/chat.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-core/src/chat.rs b/crates/dig-node-core/src/chat.rs index be91859..7989e26 100644 --- a/crates/dig-node-core/src/chat.rs +++ b/crates/dig-node-core/src/chat.rs @@ -13,6 +13,7 @@ //! [`ChatMessage::envelope`] carries it verbatim); //! 2. the **outer** [`dig_message`] e2e seal to the recipient's BLS identity key, which is what //! dig-gossip actually carries over opcode 220. +//! //! A relay or on-path peer sees only the outer ciphertext; even a peer that terminates the outer seal //! would still face the inner `DIGCHAT1` seal. The node is content-blind by construction. //! @@ -227,7 +228,11 @@ pub fn seal_outbound( recipient_pub, message_type: ID_CHAT_MESSAGE.0, shape: InteractionShape::OneShot, - correlation_id: message_id, + // A one-shot chat message needs no request/response correlation, and correlation_id is a + // CLEARTEXT envelope header — so it is left zero rather than carrying the message id, which + // would leak that id in plaintext on the wire (NC-1). The message id travels sealed inside the + // ChatMessage payload above. + correlation_id: Bytes32::from([0u8; 32]), stream: None, counter, timestamp_ms: now, From 8c8af70120c9f36dd03b3a7c6836b508ed8c6577 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 2 Aug 2026 05:08:43 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(chat):=20mark=20the=20live=20inbound?= =?UTF-8?q?=20feed=20as=20deferred=20in=20SPEC=20=C2=A75.5.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbound handler (process_inbound_frame) is implemented + unit-tested, but the run_peer_network loop that feeds it is not yet wired, so chat.poll returns empty in the shipped build until that lands. State this explicitly so an integrator does not build against chat.poll expecting delivery. Addresses the review gate's SPEC-accuracy finding. Refs #793 Co-Authored-By: Claude --- SPEC.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2f628dd..21b6aea 100644 --- a/SPEC.md +++ b/SPEC.md @@ -913,12 +913,18 @@ the plaintext body nor the plaintext message id appears in the on-wire sealed by **Inbound path.** A received opcode-220 frame is opened (`dig-message` unseal → BLS-G2 signature verify → anti-replay → expiry), routed through the chat `MessageRegistry`, and the decoded `ChatMessage` is queued into the inbox. A sender the node cannot resolve to a BLS key is rejected, -never queued (fail-closed). - -**Deferred (epic #793):** the sealing-key directory (`resolveSealingKey`) that maps a recipient -DID to its attested `0x0010` BLS key + gossip `PeerId`, and the inbound sender-key resolver, are -NOT in this MVP — the app supplies `recipient_pub` + `peer_id` on `chat.send`, and the inbound -resolver is caller-supplied. Group chat, onion routing, and receipt UX are out of scope; the five +never queued (fail-closed). This describes the inbound handler (`process_inbound_frame`), which is +implemented and unit-tested; the **live peer-network feed that invokes it is not yet wired** into +`run_peer_network` (see Deferred), so in the shipped build `chat.poll` returns empty until that +loop lands. + +**Deferred (epic #793):** the **live inbound feed** — the `run_peer_network` loop that drains +`GossipHandle::inbound_receiver()` into `process_inbound_frame` — is not yet wired (the handler is +implemented + unit-tested but nothing in production calls it, so `chat.poll` is always empty until +this lands; it is gated on the sender-key resolver below). The sealing-key directory +(`resolveSealingKey`) that maps a recipient DID to its attested `0x0010` BLS key + gossip `PeerId`, +and the inbound sender-key resolver, are NOT in this MVP — the app supplies `recipient_pub` + +`peer_id` on `chat.send`, and the inbound resolver is caller-supplied. Group chat, onion routing, and receipt UX are out of scope; the five chat message types (message, delivery/read receipts, typing, presence) are defined in `dig-chat-protocol` but only `ChatMessage` surfaces to `chat.poll`.