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..21b6aea 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,52 @@ 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). 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`. + ### 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..7989e26 --- /dev/null +++ b/crates/dig-node-core/src/chat.rs @@ -0,0 +1,649 @@ +//! 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, + // 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, + 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, + }, ] }