From ca08c72dacb5cbf8843fc7ff1b2d05aafdeab2a3 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Fri, 4 Sep 2026 17:09:45 -0400 Subject: [PATCH 1/4] docs: propose BRC-188 Encrypted Group Messaging over Type-42 Key Derivation --- README.md | 1 + SUMMARY.md | 1 + peer-to-peer/0188.md | 298 ++++++++++++++++++++++++++++++ peer-to-peer/README.md | 1 + peer-to-peer/media/0188-verify.py | 18 ++ 5 files changed, 319 insertions(+) create mode 100644 peer-to-peer/0188.md create mode 100644 peer-to-peer/media/0188-verify.py diff --git a/README.md b/README.md index 100a9bbf..478caa16 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ BRC | Standard 174 | [Consensus-Unique Name Tokens — Identity Binding and Verified Resolution](./tokens/0174.md) 176 | [BSV-21 — Validity Proofs](./tokens/0176.md) 177 | [Wallet-Enforced Expiry for `noSend` Actions](./wallet/0177.md) +188 | [Encrypted Group Messaging over Type-42 Key Derivation](./peer-to-peer/0188.md) 190 | [Access Gates for Metanet Rooms](./apps/0190.md) 210 | [Derived Collectibles](./apps/0210.md) 218 | [Chat-Native Command Grammar for the Metanet](./apps/0218.md) diff --git a/SUMMARY.md b/SUMMARY.md index 11501cf9..8cba4b73 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -182,6 +182,7 @@ * [Single-Use Signed Proofs for Request Authentication](./peer-to-peer/0138.md) * [Fountain-Coded Air-Gap Transport for Arbitrary Payloads](./peer-to-peer/0141.md) * [Universal Handle Addressing and Resolution for the Metanet](./peer-to-peer/0169.md) +* [Encrypted Group Messaging over Type-42 Key Derivation](./peer-to-peer/0188.md) * [Animated-QR Air-Gap Transport for Arbitrary Payloads (TKQR1)](./peer-to-peer/0225.md) * [Keyed Content and Conditional Key Release](./peer-to-peer/0369.md) diff --git a/peer-to-peer/0188.md b/peer-to-peer/0188.md new file mode 100644 index 00000000..3ff0f367 --- /dev/null +++ b/peer-to-peer/0188.md @@ -0,0 +1,298 @@ +# BRC-188: Encrypted Group Messaging over Type-42 Key Derivation + +Open Protocol Labs (info@opl.dev) + +**Authors:** Luke Rohenaz (luke@opl.dev) + +**Contributors:** Kurt Wuckert Jr. (kurt@opl.dev), David Case (dcase@opl.dev), Michael Boyd (root@opl.dev), Dan Wagner (dan@opl.dev) + +> **Status: experimental design draft.** No interoperable implementation or complete protocol test suite is established. The bootstrap and membership-state issues in “Open Design Requirements” are blockers to use with real conversations. + +## Abstract + +This BRC specifies end-to-end encrypted group messaging for BSV applications, built entirely on cryptographic primitives already native to the ecosystem: secp256k1 keys, ECDH, ECDSA, and [BRC-42](../key-derivation/0042.md)/[BRC-43](../key-derivation/0043.md) key derivation. Two profiles are defined. **Profile A (pairwise fan-out)** encrypts each group message once per member over existing [BRC-78](./0078.md) pairwise channels — the required baseline, suitable for small groups. **Profile B (sender keys)** adapts Matrix Megolm’s hash ratchet with a new cipher/signature suite and wire format: each member distributes a per-session ratchet state and signing key once over pairwise channels, then encrypts each message exactly once for the whole group. + +The primitives and sender-key pattern have prior art, but this composition, wire format, and control protocol are new and do not inherit another protocol’s security analysis automatically. It composes with the existing stack — [BRC-78](./0078.md) pairwise encryption, [BRC-33](./0033.md) messagebox transport, [BRC-169](./0169.md) envelopes and reachability, and optionally [BRC-190](../apps/0190.md) room membership — and supplies the one capability that stack explicitly delegates: keying a room's contents so that non-members cannot decrypt them. + +## Motivation + +The BSV messaging stack answers who may *deliver* to an identity (BRC-169 §8 scopes and tolls, enforced at the messagebox) and what a conforming client *renders* (BRC-190 access gates), and BRC-190 §9 is explicit that neither is confidentiality: "A room whose confidentiality matters must key its contents so that non-holders cannot decrypt them; that mechanism is out of scope here." BRC-78 provides real encryption but is strictly two-party: one sender, one recipient, `anyone` prohibited, no shared-key mechanism of any kind. + +BRC-78 supplies pairwise portable encryption but no group membership or sender-key state. This proposal explores that additional layer. Its hash ratchet limits exposure from a later ratchet-state-only compromise when old states are erased, but its static-key BRC-78 session distribution does not provide end-to-end forward secrecy against later identity-key compromise. + +## Specification + +The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" are to be interpreted as described in RFC 2119. + +### 1. Layering and Prerequisites + +| Layer | Provided by | This document's use | +|---|---|---| +| Identity | secp256k1 identity keys; optionally BAP ([BRC-186](https://github.com/opldotdev/BRCs/blob/codex/brc-186-bap/peer-to-peer/0186.md)) | Members are identified by compressed identity public keys | +| Pairwise channels | [BRC-78](./0078.md) portable encrypted messages over [BRC-42](../key-derivation/0042.md)/[BRC-43](../key-derivation/0043.md) | Carries all control messages (Section 6) | +| Transport | [BRC-33](./0033.md) messageboxes; [BRC-169](./0169.md) §7 envelopes | Off-chain delivery of control and group messages (Section 8) | +| Membership authority (optional) | [BRC-190](../apps/0190.md) rooms | Qualification, roles, bans, and rekey triggers (Section 9) | + +A pairwise channel between two members exists when each can address the other under BRC-78. Applications using the Bitcoin Schema social graph establish this with the on-chain `friend` exchange ([BRC-187](https://github.com/opldotdev/BRCs/blob/codex/brc-187-social-schema/apps/0187.md)); the mechanism of introduction is out of scope here — this document requires only that a pairwise BRC-78 channel exists between the inviter and the invitee. + +All multi-byte integers in this document are big-endian unless stated otherwise. All public keys are 33-byte compressed secp256k1 points. "Identity signature" means an ECDSA signature by a member's identity private key, encoded as a 64-byte compact signature `r ‖ s` with low-S normalization, over the SHA-256 of the stated bytes. + +### 2. Groups, Sessions, and Members + +- A **group** is an ordered-by-nobody set of member identity keys sharing a conversation. A group is identified by a **group ID**: 16 cryptographically random bytes, generated by the creator. A group MAY additionally be bound to a BRC-190 room (Section 9); the group ID remains the cryptographic identifier. +- A **member** is an identity key currently entitled to hold the group's key material. +- Under Profile B, each member maintains one **outbound session** per group: a ratchet state plus a session signing keypair. Each member holds one **inbound session** per (group, other member) pair, received via control messages. A **session ID** is 16 cryptographically random bytes generated by the session's owner. +- The **creator** of a group is its initial administrator. For standalone groups (no BRC-190 binding), administration semantics are defined in Section 7; for room-bound groups, Section 9 defers roles to BRC-190. + +### 3. Profile A — Pairwise Fan-Out (REQUIRED) + +Every conforming implementation MUST support Profile A. To send a group message under Profile A, the sender encrypts the Profile A frame below separately to each other member as a BRC-78 portable encrypted message, and delivers each per Section 8. + +The proposed Profile A plaintext is `0x10 || groupID[16] || messageID[16] || content || identitySignature[64]`. `messageID` is fresh cryptographic randomness for each logical message; the same ID is used for every recipient and retry. `content` is Section 5.4 framing. The signature covers SHA-256 of all preceding frame bytes and is verified against the authenticated BRC-78 sender identity, which MUST be a currently authorized member. Reject fewer than 98 plaintext bytes. Receivers MUST durably reject repeated `(groupID, senderIdentity, messageID)` tuples after successful authentication and decryption. Cross-profile retry deduplication is not yet specified (see Open Design Requirements). + +Profile A has per-message cost linear in group size, and membership change requires no rekey: members simply start or stop being addressed. Implementations SHOULD use Profile A for small groups and MUST use it as the fallback whenever a Profile B session is unavailable for a recipient. + +Forward secrecy note: Profile A inherits BRC-78's key schedule — a fresh random 256-bit key ID per message yields per-message derived keys, but all derivations stem from static identity keys, so compromise of an identity key compromises past traffic. Profile A trades scalability and forward-secrecy hygiene for maximum simplicity. + +### 4. Profile B — Sender Keys (Experimental) + +Profile B adopts the Megolm session-ratchet design (Apache-2.0 specification by the Matrix.org team), with two deliberate departures stated here once: the payload AEAD is **AES-256-GCM** (aligning with BRC-78's ecosystem-wide cipher choice) rather than Megolm's AES-256-CBC with truncated HMAC, and the signing algorithm is **secp256k1 ECDSA** rather than Ed25519. The ratchet mathematics are unchanged; the complete protocol is not wire compatible with Megolm. + +#### 4.1 Session State + +An outbound session consists of: + +- A **ratchet state** `R_i`: four 32-byte parts `R_{i,0}, R_{i,1}, R_{i,2}, R_{i,3}` (128 bytes total), initialized from a cryptographically secure random source, with a 32-bit counter `i` starting at 0. +- A **session signing keypair**: a fresh, random secp256k1 keypair generated for this session, used for nothing else. It is bound to the member's identity by the identity signature on the session-share control message (Section 6.1), not by derivation. + +#### 4.2 Ratchet Advancement + +Define, for `j` in 0..3: + +``` +H_j(A) = HMAC-SHA-256(key = A, message = byte(j)) +``` + +where `byte(j)` is the single byte `0x00`, `0x01`, `0x02`, or `0x03`. Advancing from `i` to `i+1`: + +- `R_{i,3}` advances every step: `R_{i+1,3} = H_3(R_{i,3})`, except at the boundaries below. +- Every `2^8` steps (when `i+1 ≡ 0 mod 2^8`), part 2 reseeds: `R_{i+1,2} = H_2(R_{i,2})` and `R_{i+1,3} = H_3(R_{i,2})`. +- Every `2^16` steps, part 1 reseeds and cascades: `R_{i+1,1} = H_1(R_{i,1})`, `R_{i+1,2} = H_2(R_{i,1})`, `R_{i+1,3} = H_3(R_{i,1})`. +- Every `2^24` steps, part 0 reseeds and cascades: `R_{i+1,0} = H_0(R_{i,0})`, `R_{i+1,1} = H_1(R_{i,0})`, `R_{i+1,2} = H_2(R_{i,0})`, `R_{i+1,3} = H_3(R_{i,0})`. + +At a boundary, apply only the largest matching boundary rule; all assignments use the old state, and parts not assigned retain their old values. + +The ratchet is one-way: earlier states cannot be computed from later ones. Winding forward from any state to any later index requires at most 1020 hash computations. A receiver holding `R_i` MUST advance (never rewind) to decrypt messages at indices `≥ i` and cannot decrypt indices `< i`. + +#### 4.3 Message Key Derivation + +For message index `i`: + +``` +KEY_i ‖ IV_i = HKDF-SHA-256(salt = 32 zero bytes, IKM = R_i (128 bytes), + info = "bsv group message keys", L = 44) +``` + +per RFC 5869, yielding a 32-byte AES-256 key and a 12-byte GCM IV. Because each ratchet index is used for exactly one encryption (Section 4.5), the derived deterministic IV never repeats under a given key; implementations MUST NOT encrypt twice at the same index. + +#### 4.4 Group Message Format + +``` +| Field | Length | Description | +|-------------|----------|----------------------------------------------------| +| Version | 4 bytes | 0x47525031 ("GRP1") | +| Group ID | 16 bytes | The group this message belongs to | +| Session ID | 16 bytes | The sender's outbound session | +| Index | 4 bytes | Ratchet index i (unsigned, big-endian) | +| Ciphertext | Variable | AES-256-GCM over the plaintext framing, using KEY_i and IV_i; the 16-byte GCM tag is appended by the AEAD | +| Signature | 64 bytes | ECDSA (compact, low-S) by the session signing key, over SHA-256 of all preceding bytes | +``` + +The AEAD additional authenticated data is the 40-byte header from Version through Index. The signature is fixed-width unsigned big-endian `r[32] || s[32]`, with `1 <= r < n` and `1 <= s <= n/2` for secp256k1 order `n`. Reject invalid points, unsupported versions, truncated fields, and packets shorter than 121 bytes (40 header + at least 1 content byte + 16 tag + 64 signature). + +Verification order: a receiver MUST verify the signature against the signing public key from the stored inbound session for (Group ID, Session ID), check replay (Section 4.6), derive the candidate state for `i`, then authenticate/decrypt. Commit the advanced state and accepted index atomically only after all checks succeed. Reject messages from members or sessions revoked in the local accepted membership state. A message referencing an unknown session is held until the corresponding session-share arrives or discarded per application policy; it MUST NOT be treated as authenticated. + +#### 4.5 Sending + +Index 0 is the first message of a new session. To send: reserve the current unused index durably, derive `KEY_i`/`IV_i`, encrypt the plaintext framing (Section 5.4), construct the message, sign it with the session signing key, and deliver the identical bytes to every member (Section 8). Advance the ratchet after encryption and erase obsolete sender state. A retry MUST resend identical serialized bytes; after a crash or backup restore where index-use history is uncertain, create a new random session before sending. The per-message encryption cost is constant in group size; delivery still requires fan-out. + +#### 4.6 Replay Protection and Ordering + +Delayed indices below the earliest retained state cannot be decrypted and MUST be reported as unavailable, not replayed as fresh messages. Retaining old states or skipped message keys trades history availability against compromise exposure; this draft does not mandate retention. + +Receivers MUST track, per inbound session, the set of indices already accepted, and MUST reject a message whose (Session ID, Index) pair has been seen. The index provides per-sender ordering; this document provides no cross-sender ordering, and applications requiring a total order must impose one at the application layer (Section 10, non-goals). + +#### 4.7 Session Lifetime and Rotation + +- A session MUST NOT exceed index `2^32 - 1`. +- A sender SHOULD rotate (create a fresh session and distribute it) after at most `2^16` messages or seven days, whichever comes first, limiting the exposure window of any compromised ratchet state. +- Receivers SHOULD discard ratchet states below the lowest index they still need, reducing exposure from compromise of retained ratchet state; this does not prevent recovery from recorded session shares. + +### 5. Control and Content Framing + +All control messages and Profile A messages use unmodified BRC-78 portable encryption, including its `2-message encryption-` derivation invoice. This draft previously substituted `group messaging` under the same BRC-78 version; that would fail decryption by standard BRC-78 implementations because the protocol ID is not a selectable wire field. Group separation is carried by the signed plaintext group ID. BRC-78’s version-byte ordering must be pinned against its implementation/vector before interoperation testing (its numeric version and displayed example differ). + +Within the decrypted BRC-78 content, a one-byte **frame type** discriminates: + +| Type | Meaning | +|------|---------| +| 0x01 | Session share (6.1) | +| 0x02 | Membership change announcement (6.2) | +| 0x03 | Group metadata (6.3) | +| 0x10 | Profile A group content (Section 3) | + +Unknown frame types MUST be ignored. + +#### 5.4 Plaintext Framing (Content Messages) + +The plaintext encrypted by either profile is: + +``` +| Field | Length | Description | +|--------------|----------|-----------------------------------------------| +| Content type | 1 byte | 0x01 = UTF-8 text; 0x02 = Bitcoin Schema record bytes; 0x03 = binary attachment; others reserved | +| Body | Variable | The message content | +``` + +Content type `0x02` carries a serialized Bitcoin Schema ([BRC-187](https://github.com/opldotdev/BRCs/blob/codex/brc-187-social-schema/apps/0187.md)) record, letting applications reuse the social vocabulary (message types, reactions) inside encrypted groups without inventing a parallel one. + +### 6. Control Messages + +#### 6.1 Session Share (0x01) + +Distributed pairwise to every member when a session is created or rotated, and to a newly added member on join: + +``` +| Field | Length | Description | +|---------------------|-----------|----------------------------------------------| +| Frame type | 1 byte | 0x01 | +| Group ID | 16 bytes | — | +| Session ID | 16 bytes | — | +| Index | 4 bytes | The ratchet index at which sharing begins | +| Ratchet state | 128 bytes | R_{i,0} ‖ R_{i,1} ‖ R_{i,2} ‖ R_{i,3} | +| Signing public key | 33 bytes | The session's signing key | +| Identity signature | 64 bytes | By the sharer's identity key, over SHA-256 of all preceding bytes | +``` + +The identity signature binds the session (and its signing key) to the sharing member's identity; receivers MUST verify it against the authenticated BRC-78 sender's identity key and current roster before storing the inbound session. The recipient identity in the BRC-78 envelope MUST be the local identity. A session ID MUST NOT be rebound to a different owner or signing key; old shares MUST NOT rewind retained ratchet state or restore revoked sessions. Senders share the state for the **next unused** index, after retiring any index already used before the join. Because the shared state is `R_i` at that index, a newly added member can decrypt from the join point forward and nothing earlier — member addition requires no rekey. + +#### 6.2 Membership Change (0x02) + +``` +| Field | Length | Description | +|--------------------|----------|-------------------------------------------| +| Frame type | 1 byte | 0x02 | +| Group ID | 16 bytes | — | +| Change | 1 byte | 0x01 add, 0x02 remove | +| Member | 33 bytes | Identity key of the affected member | +| Identity signature | 64 bytes | By an authorized member (Section 7/9), over SHA-256 of all preceding bytes | +``` + +**Rekey on removal is mandatory.** Upon accepting a remove (or a BRC-190 ban or exclusion, Section 9), every remaining member MUST discard the removed member's inbound sessions, MUST create a fresh outbound session, and MUST distribute it pairwise to the remaining members only. Until a member has rekeyed, its subsequent messages remain readable by the removed member; clients MUST finish their own rekey before their next send and MUST pause sending if rekey cannot complete. A removed recipient cannot be made to forget already received plaintext or keys. A full rekey is O(n²) pairwise messages across the group; this is the accepted cost of the sender-keys design (Section 11). + +#### 6.3 Group Metadata (0x03) + +An application-defined payload (name, avatar reference, topic), signed by an authorized member. Its schema is out of scope; the frame exists so metadata travels encrypted rather than as messagebox-visible envelope fields. + +### 7. Standalone Group Administration + +For groups not bound to a BRC-190 room: + +- The creator is the initial administrator. Administrators MAY add and remove members and MAY designate additional administrators via membership-change frames (change `0x03` add-admin, `0x04` remove-admin, same layout as 6.2). +- Any member MAY send content. Members learn the roster from add/remove frames; a client MUST accept membership changes only when the identity signature is by a current administrator. +- Two administrators removing each other concurrently is resolved by accepting the change whose carrying message was received first at each client; this document does not provide consensus on the roster, and applications requiring stronger membership agreement should bind the group to a BRC-190 room. + +### 8. Transport Mapping + +- **BRC-33 / BRC-169 messageboxes are the normative transport.** A group or control message is delivered to each member as one BRC-169 §7.2 envelope whose `content` member carries: for control messages and Profile A, the BRC-78 blob; for Profile B group messages, the Section 4.4 message bytes. The envelope's own rules (signature over canonicalized metadata excluding content, scope and toll enforcement at the recipient's messagebox) apply unchanged; nothing in this document extends §7.2. Profile B exposes its `GRP1` marker, group ID, session ID, and index to the messagebox; these allow direct correlation of members and traffic. Pairwise control frames hide those plaintext fields but still reveal communication metadata. +- Fan-out under a `toll` scope means one toll payment per member envelope, each with its own single-use quote, per BRC-169 §8.3. +- **On-chain anchoring is OPTIONAL and minimal.** The pairwise-channel prerequisite (e.g. the BRC-187 `friend` exchange) is on-chain by that protocol's own design. This document defines no on-chain group record: publishing membership on-chain makes the membership graph public forever, and the roster is already carried encrypted by Section 6. Applications wanting a durable public anchor for group existence MAY publish one (for example a Bitcoin Schema record naming only the group ID) and should weigh the permanence of that disclosure; room-bound groups get their public identity from the BRC-190 room instead. + +### 9. Composition with BRC-190 Rooms + +A group MAY be bound at creation to a BRC-190 room by including the room identifier (`room:/`) in the group metadata frame. For a room-bound group: + +1. **Qualification gates invitation.** A member MUST NOT send a session share to an identity whose current gate verdict is not `qualifies`. (A verdict of `indeterminate` is not `qualifies`.) +2. **Roles govern administration.** BRC-190 condition-derived roles replace Section 7: mods and admins are the authorized signers for membership changes, and the room holder's custody powers are as BRC-190 defines them. +3. **A ban is an immediate removal.** BRC-190 evaluates bans first and immediately; on observing a ban, remaining members MUST treat it as a Section 6.2 removal — discard sessions, rekey now. +4. **Rule-change exclusion rekeys at `effectiveFrom`.** BRC-190 §2.4 requires a manifest change that newly excludes a current participant to carry an `effectiveFrom` at least 1,008 blocks out, with the old manifest deciding until then. The cryptographic rekey binds to when exclusion takes *effect*, not when it is announced: members MUST rekey (excluding the affected participant) when the chain reaches `effectiveFrom`, and MUST NOT rekey earlier on account of the pending change — the participant remains a member, and a decryption capability removed early would contradict the manifest that still decides. The notice period is thereby preserved intact, and the tension between the week of notice and immediate rekey-on-remove dissolves: immediacy applies to bans and explicit removals, `effectiveFrom` to rule changes. +5. **Lapsing is not retroactive** (BRC-190 §6): a participant who lapses and later requalifies missed the sessions rotated in between; they rejoin by receiving fresh session shares like any added member. + +BRC-190's own caveat is inherited: vouch and quorum gates depend on attestation discovery; a v1 room-bound group SHOULD gate on conditions evaluable today (holding, timelock, renounce) or accept `indeterminate` verdicts as non-qualifying. + +### 10. Non-Goals + +The following are explicitly out of scope for this version: post-compromise security beyond session rotation (see Section 11); transcript consistency and total message ordering; metadata privacy (BRC-169's Security Considerations apply in full — the messagebox operator sees who contacts whom, and group fan-out patterns are visible traffic analysis); multi-device synchronization and history storage (BRC-33 deletes on acknowledgment; history is the client's concern and nothing here precludes a client-side store); public channels (unencrypted; covered by BRC-187 `message` with `context channel`); stranger invitations (invitation requires an existing pairwise channel); zero-knowledge private group state; and post-quantum security. + +### 11. Security Considerations + +This section states the construction's known limits plainly; a client or product description that claims more than the following is wrong. + +- **Ratchet-state-only compromise.** A state at index `i` derives keys from `i` onward; recovering earlier keys requires an earlier retained state or another source. Erasing old states reduces this exposure. This is a conditional property, not an end-to-end forward-secrecy guarantee. +- **No post-compromise security.** An adversary who obtains a sender key can passively read that sender's session indefinitely until rotation; the hash ratchet never self-heals. This is a documented property of the sender-keys design (Megolm "Limitations"; RFC 9420 §1; Balbás–Collins–Gajland, ASIACRYPT 2023). A fresh session can contain a ratchet-state-only compromise if it is distributed over an uncompromised channel. Rotation is recommended and removal rekey is mandatory; neither repairs a compromised identity/distribution channel by itself. +- **Rekey is O(n²) and mandatory on removal.** Until every member rekeys, a removed member reads the non-rekeyed senders. Sending pauses until the local sender has rekeyed, as required by 6.2. +- **Replay.** Without the 4.6 index tracking, any observer of ciphertext can replay old messages; tracking is therefore REQUIRED, not advisory. +- **The pairwise channel is load-bearing.** Session shares inherit every weakness of the channel that carries them, including unknown-key-share ambiguities; the identity signature inside the share (6.1) is the defense this document adds, and receivers who skip its verification void the group's authenticity entirely. Note that BRC-78's AEAD alone provides no third-party-attributable sender authentication — both parties can compute the key — which is why the specified control frames carry identity signatures. These signatures alone do not fix replay, membership-state agreement, or recipient-context binding. +- **No transcript consistency.** Nothing guarantees all members saw the same messages; per-sender indices guarantee only per-sender gap detection. +- **No deniability claim.** Content messages are signed by session keys (linkable to identities via signed session shares held by members); this document makes no off-the-record claim. +- **Forward secrecy of the fielded stack.** An attacker who records BRC-78 session-share ciphertexts and later obtains an involved identity private key can derive the wrapping keys, recover earlier shared ratchet states, and decrypt historical group content. Erasing local ratchet states does not erase those recorded shares. Rotation does not heal a compromised identity key when replacement sessions use the same compromised distribution channel. A forward-secure, authenticated pairwise key exchange and identity recovery procedure are prerequisites for a stronger claim. +- **secp256k1 ECDH implementation note.** Deriving shared secrets on secp256k1 has a known footgun: some libraries hash the ECDH output by default while others return the raw x-coordinate. BRC-42's shared secret is the ECDH point per that specification and its test vectors; implementations MUST validate against the BRC-42 vectors rather than assuming their library's default (compare the analogous warning in Nostr NIP-44, which specifies the unhashed x-coordinate and warns against libsecp256k1's hashing default). +- **Legacy cohort.** Deployed applications carrying Electrum-ECIES ("BIE1", AES-128-CBC, deterministic KDF-derived IV) direct messages and BIP32-path friend keys are a distinct legacy cohort. This document does not accept those constructions: new group and pairwise traffic under this specification uses BRC-42/43/78 exclusively, and clients supporting legacy history SHOULD treat it as read-only. + +### 12. Future Work + +- **MLS (RFC 9420) migration** following the Marmot precedent from the Nostr ecosystem: standard-ciphersuite MLS with separate per-group keys, the secp256k1 identity key binding identity via the MLS credential — sidestepping the absence of a standardized secp256k1 ciphersuite. TreeKEM would replace this document's O(n²) rekey with O(log n) and add true post-compromise security. +- **DHKEM-secp256k1** (CFRG draft-wahby-cfrg-hpke-kem-secp256k1) as the path to a native ciphersuite. +- **Schnorr (BIP340) signatures** for session and identity signatures as ecosystem tooling adopts them. +- Post-quantum hybrid ratchets (Signal Double Ratchet revision 4's documented variants). +- Ratcheted 1:1 channels (a ported Double Ratchet as a BRC-78 successor), which would require an explicitly versioned integration and new interoperability tests. +- Broadcast channels (owner-published feeds) — a different threat model, likely a sibling BRC. +- Metadata privacy (sealed-sender analogs over BRC-33). + +## Primitive Test Vector + +For test-only initial ratchet bytes `00 01 02 ... 7f`, index 0, the Section 4.3 HKDF yields: + +```text +KEY_0 = 95806396f223a283858523238b9011ac6253130bd897e405601470da3e28fb79 +IV_0 = d204791ff5204d1cc5fa2e59 +SHA256(R_256) = adb1b84e4b3c23c90fff5ded6e8fcfcd0a6210fd6c5c9d2cf463a93c2b7f3985 +``` + +The last line follows 256 consecutive Section 4.2 advances from that initial state. These values were computed with Python standard-library HMAC/SHA-256. They test only ratchet/KDF arithmetic, not encryption, signatures, wire compatibility, or group-state security. Complete protocol vectors remain a blocker below. + +## Open Design Requirements + +This draft is not a complete independent-implementation specification until the following are resolved: + +1. **Authenticated bootstrap and membership epochs.** Define the group-creation record, trusted creator discovery, initial roster, profile negotiation, and a signed monotonic epoch/predecessor binding for every control and content frame. Current add/remove frames have no replay identifier or ordering field: replaying a prior authorized add can re-admit a removed member. A local replay cache alone does not solve delivery to a new client. Room membership does not supply this missing cryptographic state protocol automatically. +2. **Conflicting administration.** First-received administrator changes can permanently split the roster and leak content to a member another client considers removed. Define recovery or authenticated agreement before claiming a shared membership boundary. +3. **Signed metadata and context.** Specify metadata serialization, signature position/preimage, immutable room binding, recipient binding for session shares, and rules preventing rollback or cross-context forwarding. The present metadata placeholder is insufficient for interoperable authentication. +4. **Profile transitions.** Define capability negotiation, retirement of older sessions, and logical-message deduplication across pairwise fallback and sender-key retransmission. Do not assume arrival order establishes these semantics. +5. **Portable vectors and review.** Supply complete byte vectors for both profiles, BRC-78 wrapping, session shares, low-S signatures, GCM AAD/tag verification, ratchet boundaries at 256/65536/16777216, malformed inputs, replay, crash recovery, joins and removals. Independently reproduce them in two implementations and obtain adversarial cryptographic review. Primitive tests alone do not validate this composition. + +No deployed wire compatibility is claimed for this new design. The exact fields and versions may need revision to close these blockers. + +## Reproducible primitive checks + +Run [the portable Python 3 verifier](./media/0188-verify.py). These checks reproduce the stated primitive vectors; they do not establish complete protocol or independent implementation interoperability. + +## Implementations + +None yet; this specification precedes its first implementation deliberately. Matrix Megolm and other sender-key protocols are prior art; none is an implementation of this wire format or control protocol. A cryptographic review of this document by a qualified adversarial reviewer is a prerequisite to any implementation carrying real conversations. + +## References + +- Megolm group ratchet (design template; Apache-2.0): https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md +- Signal Double Ratchet, revision 4: https://signal.org/docs/specifications/doubleratchet/ +- Signal X3DH: https://signal.org/docs/specifications/x3dh/ +- MLS, RFC 9420: https://datatracker.ietf.org/doc/html/rfc9420 +- Marmot Protocol (MLS over Nostr): https://github.com/marmot-protocol/marmot +- Balbás, Collins, Gajland — "WhatsUpp with Sender Keys? Analysis, Improvements and Security Proofs" (ASIACRYPT 2023): https://eprint.iacr.org/2023/1385 ; companion analysis: https://arxiv.org/abs/2301.07045 +- Nostr NIP-44 (secp256k1 payload reference and ECDH caveats): https://github.com/nostr-protocol/nips/blob/master/44.md +- HKDF, RFC 5869: https://www.rfc-editor.org/rfc/rfc5869 +- DHKEM-secp256k1 CFRG draft: https://datatracker.ietf.org/doc/draft-wahby-cfrg-hpke-kem-secp256k1/ +- [BRC-42: BSV Key Derivation Scheme](../key-derivation/0042.md) · [BRC-43: Security Levels, Protocol IDs, Key IDs and Counterparties](../key-derivation/0043.md) +- [BRC-78: Serialization Format for Portable Encrypted Messages](./0078.md) +- [BRC-33: PeerServ Message Relay Interface](./0033.md) · [BRC-169: Universal Handle Addressing and Resolution for the Metanet](./0169.md) +- [BRC-190: Access Gates for Metanet Rooms](../apps/0190.md) · [BRC-186: BAP — Bitcoin Attestation Protocol](https://github.com/opldotdev/BRCs/blob/codex/brc-186-bap/peer-to-peer/0186.md) · [BRC-187: Bitcoin Schema — Social Data Types](https://github.com/opldotdev/BRCs/blob/codex/brc-187-social-schema/apps/0187.md) diff --git a/peer-to-peer/README.md b/peer-to-peer/README.md index 290b48c2..77dd8dbc 100644 --- a/peer-to-peer/README.md +++ b/peer-to-peer/README.md @@ -19,5 +19,6 @@ BRC | Standard 138 | [Single-Use Signed Proofs for Request Authentication](./0138.md) 141 | [Fountain-Coded Air-Gap Transport for Arbitrary Payloads](./0141.md) 169 | [Universal Handle Addressing and Resolution for the Metanet](./0169.md) +188 | [Encrypted Group Messaging over Type-42 Key Derivation](./0188.md) 225 | [Animated-QR Air-Gap Transport for Arbitrary Payloads (TKQR1)](./0225.md) 369 | [Keyed Content and Conditional Key Release](./0369.md) diff --git a/peer-to-peer/media/0188-verify.py b/peer-to-peer/media/0188-verify.py new file mode 100644 index 00000000..57837ad6 --- /dev/null +++ b/peer-to-peer/media/0188-verify.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""BRC-513 primitive-only fixture: NOT a security or interoperability test.""" +import hashlib +import hmac +H = lambda k, m: hmac.new(k, m, hashlib.sha256).digest() +r = bytes(range(128)) +prk = H(bytes(32), r) +t1 = H(prk, b'bsv group message keys\x01') +t2 = H(prk, t1 + b'bsv group message keys\x02') +assert t1.hex() == '95806396f223a283858523238b9011ac6253130bd897e405601470da3e28fb79' +assert t2[:12].hex() == 'd204791ff5204d1cc5fa2e59' +parts = [r[i:i+32] for i in range(0,128,32)] +for i in range(1,257): + j = 0 if i % 2**24 == 0 else 1 if i % 2**16 == 0 else 2 if i % 256 == 0 else 3 + old = parts[j] + parts[j:] = [H(old, bytes([k])) for k in range(j,4)] +assert hashlib.sha256(b''.join(parts)).hexdigest() == 'adb1b84e4b3c23c90fff5ded6e8fcfcd0a6210fd6c5c9d2cf463a93c2b7f3985' +print('PASS: group HKDF and ratchet 256 boundary only; no AEAD/wire/membership assertions') From 31d8a6faa576101d5d45edd3efd43547be74e48c Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Fri, 4 Sep 2026 18:15:31 -0400 Subject: [PATCH 2/4] docs(groups): define signed epoch baseline with executable fixtures --- peer-to-peer/0188.md | 297 +++------------------------ peer-to-peer/media/0188-vectors.json | 25 +++ peer-to-peer/media/0188-verify.py | 185 +++++++++++++++-- 3 files changed, 221 insertions(+), 286 deletions(-) create mode 100644 peer-to-peer/media/0188-vectors.json diff --git a/peer-to-peer/0188.md b/peer-to-peer/0188.md index 3ff0f367..b06e5a6c 100644 --- a/peer-to-peer/0188.md +++ b/peer-to-peer/0188.md @@ -1,298 +1,57 @@ -# BRC-188: Encrypted Group Messaging over Type-42 Key Derivation +# BRC-188: Encrypted Group Messaging over BRC-78 -Open Protocol Labs (info@opl.dev) - -**Authors:** Luke Rohenaz (luke@opl.dev) - -**Contributors:** Kurt Wuckert Jr. (kurt@opl.dev), David Case (dcase@opl.dev), Michael Boyd (root@opl.dev), Dan Wagner (dan@opl.dev) - -> **Status: experimental design draft.** No interoperable implementation or complete protocol test suite is established. The bootstrap and membership-state issues in “Open Design Requirements” are blockers to use with real conversations. +**Authors:** Luke Rohenaz; contributors Kurt Wuckert Jr., David Case, Michael Boyd, and Dan Wagner. **Status: non-production design draft.** ## Abstract -This BRC specifies end-to-end encrypted group messaging for BSV applications, built entirely on cryptographic primitives already native to the ecosystem: secp256k1 keys, ECDH, ECDSA, and [BRC-42](../key-derivation/0042.md)/[BRC-43](../key-derivation/0043.md) key derivation. Two profiles are defined. **Profile A (pairwise fan-out)** encrypts each group message once per member over existing [BRC-78](./0078.md) pairwise channels — the required baseline, suitable for small groups. **Profile B (sender keys)** adapts Matrix Megolm’s hash ratchet with a new cipher/signature suite and wire format: each member distributes a per-session ratchet state and signing key once over pairwise channels, then encrypts each message exactly once for the whole group. - -The primitives and sender-key pattern have prior art, but this composition, wire format, and control protocol are new and do not inherit another protocol’s security analysis automatically. It composes with the existing stack — [BRC-78](./0078.md) pairwise encryption, [BRC-33](./0033.md) messagebox transport, [BRC-169](./0169.md) envelopes and reachability, and optionally [BRC-190](../apps/0190.md) room membership — and supplies the one capability that stack explicitly delegates: keying a room's contents so that non-members cannot decrypt them. - -## Motivation - -The BSV messaging stack answers who may *deliver* to an identity (BRC-169 §8 scopes and tolls, enforced at the messagebox) and what a conforming client *renders* (BRC-190 access gates), and BRC-190 §9 is explicit that neither is confidentiality: "A room whose confidentiality matters must key its contents so that non-holders cannot decrypt them; that mechanism is out of scope here." BRC-78 provides real encryption but is strictly two-party: one sender, one recipient, `anyone` prohibited, no shared-key mechanism of any kind. - -BRC-78 supplies pairwise portable encryption but no group membership or sender-key state. This proposal explores that additional layer. Its hash ratchet limits exposure from a later ratchet-state-only compromise when old states are erased, but its static-key BRC-78 session distribution does not provide end-to-end forward secrecy against later identity-key compromise. - -## Specification - -The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" are to be interpreted as described in RFC 2119. - -### 1. Layering and Prerequisites - -| Layer | Provided by | This document's use | -|---|---|---| -| Identity | secp256k1 identity keys; optionally BAP ([BRC-186](https://github.com/opldotdev/BRCs/blob/codex/brc-186-bap/peer-to-peer/0186.md)) | Members are identified by compressed identity public keys | -| Pairwise channels | [BRC-78](./0078.md) portable encrypted messages over [BRC-42](../key-derivation/0042.md)/[BRC-43](../key-derivation/0043.md) | Carries all control messages (Section 6) | -| Transport | [BRC-33](./0033.md) messageboxes; [BRC-169](./0169.md) §7 envelopes | Off-chain delivery of control and group messages (Section 8) | -| Membership authority (optional) | [BRC-190](../apps/0190.md) rooms | Qualification, roles, bans, and rekey triggers (Section 9) | - -A pairwise channel between two members exists when each can address the other under BRC-78. Applications using the Bitcoin Schema social graph establish this with the on-chain `friend` exchange ([BRC-187](https://github.com/opldotdev/BRCs/blob/codex/brc-187-social-schema/apps/0187.md)); the mechanism of introduction is out of scope here — this document requires only that a pairwise BRC-78 channel exists between the inviter and the invitee. - -All multi-byte integers in this document are big-endian unless stated otherwise. All public keys are 33-byte compressed secp256k1 points. "Identity signature" means an ECDSA signature by a member's identity private key, encoded as a 64-byte compact signature `r ‖ s` with low-S normalization, over the SHA-256 of the stated bytes. - -### 2. Groups, Sessions, and Members - -- A **group** is an ordered-by-nobody set of member identity keys sharing a conversation. A group is identified by a **group ID**: 16 cryptographically random bytes, generated by the creator. A group MAY additionally be bound to a BRC-190 room (Section 9); the group ID remains the cryptographic identifier. -- A **member** is an identity key currently entitled to hold the group's key material. -- Under Profile B, each member maintains one **outbound session** per group: a ratchet state plus a session signing keypair. Each member holds one **inbound session** per (group, other member) pair, received via control messages. A **session ID** is 16 cryptographically random bytes generated by the session's owner. -- The **creator** of a group is its initial administrator. For standalone groups (no BRC-190 binding), administration semantics are defined in Section 7; for room-bound groups, Section 9 defers roles to BRC-190. - -### 3. Profile A — Pairwise Fan-Out (REQUIRED) - -Every conforming implementation MUST support Profile A. To send a group message under Profile A, the sender encrypts the Profile A frame below separately to each other member as a BRC-78 portable encrypted message, and delivers each per Section 8. - -The proposed Profile A plaintext is `0x10 || groupID[16] || messageID[16] || content || identitySignature[64]`. `messageID` is fresh cryptographic randomness for each logical message; the same ID is used for every recipient and retry. `content` is Section 5.4 framing. The signature covers SHA-256 of all preceding frame bytes and is verified against the authenticated BRC-78 sender identity, which MUST be a currently authorized member. Reject fewer than 98 plaintext bytes. Receivers MUST durably reject repeated `(groupID, senderIdentity, messageID)` tuples after successful authentication and decryption. Cross-profile retry deduplication is not yet specified (see Open Design Requirements). - -Profile A has per-message cost linear in group size, and membership change requires no rekey: members simply start or stop being addressed. Implementations SHOULD use Profile A for small groups and MUST use it as the fallback whenever a Profile B session is unavailable for a recipient. - -Forward secrecy note: Profile A inherits BRC-78's key schedule — a fresh random 256-bit key ID per message yields per-message derived keys, but all derivations stem from static identity keys, so compromise of an identity key compromises past traffic. Profile A trades scalability and forward-secrecy hygiene for maximum simplicity. - -### 4. Profile B — Sender Keys (Experimental) - -Profile B adopts the Megolm session-ratchet design (Apache-2.0 specification by the Matrix.org team), with two deliberate departures stated here once: the payload AEAD is **AES-256-GCM** (aligning with BRC-78's ecosystem-wide cipher choice) rather than Megolm's AES-256-CBC with truncated HMAC, and the signing algorithm is **secp256k1 ECDSA** rather than Ed25519. The ratchet mathematics are unchanged; the complete protocol is not wire compatible with Megolm. - -#### 4.1 Session State - -An outbound session consists of: - -- A **ratchet state** `R_i`: four 32-byte parts `R_{i,0}, R_{i,1}, R_{i,2}, R_{i,3}` (128 bytes total), initialized from a cryptographically secure random source, with a 32-bit counter `i` starting at 0. -- A **session signing keypair**: a fresh, random secp256k1 keypair generated for this session, used for nothing else. It is bound to the member's identity by the identity signature on the session-share control message (Section 6.1), not by derivation. - -#### 4.2 Ratchet Advancement - -Define, for `j` in 0..3: - -``` -H_j(A) = HMAC-SHA-256(key = A, message = byte(j)) -``` - -where `byte(j)` is the single byte `0x00`, `0x01`, `0x02`, or `0x03`. Advancing from `i` to `i+1`: - -- `R_{i,3}` advances every step: `R_{i+1,3} = H_3(R_{i,3})`, except at the boundaries below. -- Every `2^8` steps (when `i+1 ≡ 0 mod 2^8`), part 2 reseeds: `R_{i+1,2} = H_2(R_{i,2})` and `R_{i+1,3} = H_3(R_{i,2})`. -- Every `2^16` steps, part 1 reseeds and cascades: `R_{i+1,1} = H_1(R_{i,1})`, `R_{i+1,2} = H_2(R_{i,1})`, `R_{i+1,3} = H_3(R_{i,1})`. -- Every `2^24` steps, part 0 reseeds and cascades: `R_{i+1,0} = H_0(R_{i,0})`, `R_{i+1,1} = H_1(R_{i,0})`, `R_{i+1,2} = H_2(R_{i,0})`, `R_{i+1,3} = H_3(R_{i,0})`. - -At a boundary, apply only the largest matching boundary rule; all assignments use the old state, and parts not assigned retain their old values. - -The ratchet is one-way: earlier states cannot be computed from later ones. Winding forward from any state to any later index requires at most 1020 hash computations. A receiver holding `R_i` MUST advance (never rewind) to decrypt messages at indices `≥ i` and cannot decrypt indices `< i`. - -#### 4.3 Message Key Derivation - -For message index `i`: - -``` -KEY_i ‖ IV_i = HKDF-SHA-256(salt = 32 zero bytes, IKM = R_i (128 bytes), - info = "bsv group message keys", L = 44) -``` - -per RFC 5869, yielding a 32-byte AES-256 key and a 12-byte GCM IV. Because each ratchet index is used for exactly one encryption (Section 4.5), the derived deterministic IV never repeats under a given key; implementations MUST NOT encrypt twice at the same index. - -#### 4.4 Group Message Format - -``` -| Field | Length | Description | -|-------------|----------|----------------------------------------------------| -| Version | 4 bytes | 0x47525031 ("GRP1") | -| Group ID | 16 bytes | The group this message belongs to | -| Session ID | 16 bytes | The sender's outbound session | -| Index | 4 bytes | Ratchet index i (unsigned, big-endian) | -| Ciphertext | Variable | AES-256-GCM over the plaintext framing, using KEY_i and IV_i; the 16-byte GCM tag is appended by the AEAD | -| Signature | 64 bytes | ECDSA (compact, low-S) by the session signing key, over SHA-256 of all preceding bytes | -``` - -The AEAD additional authenticated data is the 40-byte header from Version through Index. The signature is fixed-width unsigned big-endian `r[32] || s[32]`, with `1 <= r < n` and `1 <= s <= n/2` for secp256k1 order `n`. Reject invalid points, unsupported versions, truncated fields, and packets shorter than 121 bytes (40 header + at least 1 content byte + 16 tag + 64 signature). - -Verification order: a receiver MUST verify the signature against the signing public key from the stored inbound session for (Group ID, Session ID), check replay (Section 4.6), derive the candidate state for `i`, then authenticate/decrypt. Commit the advanced state and accepted index atomically only after all checks succeed. Reject messages from members or sessions revoked in the local accepted membership state. A message referencing an unknown session is held until the corresponding session-share arrives or discarded per application policy; it MUST NOT be treated as authenticated. - -#### 4.5 Sending - -Index 0 is the first message of a new session. To send: reserve the current unused index durably, derive `KEY_i`/`IV_i`, encrypt the plaintext framing (Section 5.4), construct the message, sign it with the session signing key, and deliver the identical bytes to every member (Section 8). Advance the ratchet after encryption and erase obsolete sender state. A retry MUST resend identical serialized bytes; after a crash or backup restore where index-use history is uncertain, create a new random session before sending. The per-message encryption cost is constant in group size; delivery still requires fan-out. - -#### 4.6 Replay Protection and Ordering - -Delayed indices below the earliest retained state cannot be decrypted and MUST be reported as unavailable, not replayed as fresh messages. Retaining old states or skipped message keys trades history availability against compromise exposure; this draft does not mandate retention. - -Receivers MUST track, per inbound session, the set of indices already accepted, and MUST reject a message whose (Session ID, Index) pair has been seen. The index provides per-sender ordering; this document provides no cross-sender ordering, and applications requiring a total order must impose one at the application layer (Section 10, non-goals). - -#### 4.7 Session Lifetime and Rotation - -- A session MUST NOT exceed index `2^32 - 1`. -- A sender SHOULD rotate (create a fresh session and distribute it) after at most `2^16` messages or seven days, whichever comes first, limiting the exposure window of any compromised ratchet state. -- Receivers SHOULD discard ratchet states below the lowest index they still need, reducing exposure from compromise of retained ratchet state; this does not prevent recovery from recorded session shares. - -### 5. Control and Content Framing - -All control messages and Profile A messages use unmodified BRC-78 portable encryption, including its `2-message encryption-` derivation invoice. This draft previously substituted `group messaging` under the same BRC-78 version; that would fail decryption by standard BRC-78 implementations because the protocol ID is not a selectable wire field. Group separation is carried by the signed plaintext group ID. BRC-78’s version-byte ordering must be pinned against its implementation/vector before interoperation testing (its numeric version and displayed example differ). - -Within the decrypted BRC-78 content, a one-byte **frame type** discriminates: - -| Type | Meaning | -|------|---------| -| 0x01 | Session share (6.1) | -| 0x02 | Membership change announcement (6.2) | -| 0x03 | Group metadata (6.3) | -| 0x10 | Profile A group content (Section 3) | - -Unknown frame types MUST be ignored. - -#### 5.4 Plaintext Framing (Content Messages) - -The plaintext encrypted by either profile is: - -``` -| Field | Length | Description | -|--------------|----------|-----------------------------------------------| -| Content type | 1 byte | 0x01 = UTF-8 text; 0x02 = Bitcoin Schema record bytes; 0x03 = binary attachment; others reserved | -| Body | Variable | The message content | -``` - -Content type `0x02` carries a serialized Bitcoin Schema ([BRC-187](https://github.com/opldotdev/BRCs/blob/codex/brc-187-social-schema/apps/0187.md)) record, letting applications reuse the social vocabulary (message types, reactions) inside encrypted groups without inventing a parallel one. - -### 6. Control Messages - -#### 6.1 Session Share (0x01) - -Distributed pairwise to every member when a session is created or rotated, and to a newly added member on join: - -``` -| Field | Length | Description | -|---------------------|-----------|----------------------------------------------| -| Frame type | 1 byte | 0x01 | -| Group ID | 16 bytes | — | -| Session ID | 16 bytes | — | -| Index | 4 bytes | The ratchet index at which sharing begins | -| Ratchet state | 128 bytes | R_{i,0} ‖ R_{i,1} ‖ R_{i,2} ‖ R_{i,3} | -| Signing public key | 33 bytes | The session's signing key | -| Identity signature | 64 bytes | By the sharer's identity key, over SHA-256 of all preceding bytes | -``` - -The identity signature binds the session (and its signing key) to the sharing member's identity; receivers MUST verify it against the authenticated BRC-78 sender's identity key and current roster before storing the inbound session. The recipient identity in the BRC-78 envelope MUST be the local identity. A session ID MUST NOT be rebound to a different owner or signing key; old shares MUST NOT rewind retained ratchet state or restore revoked sessions. Senders share the state for the **next unused** index, after retiring any index already used before the join. Because the shared state is `R_i` at that index, a newly added member can decrypt from the join point forward and nothing earlier — member addition requires no rekey. - -#### 6.2 Membership Change (0x02) - -``` -| Field | Length | Description | -|--------------------|----------|-------------------------------------------| -| Frame type | 1 byte | 0x02 | -| Group ID | 16 bytes | — | -| Change | 1 byte | 0x01 add, 0x02 remove | -| Member | 33 bytes | Identity key of the affected member | -| Identity signature | 64 bytes | By an authorized member (Section 7/9), over SHA-256 of all preceding bytes | -``` - -**Rekey on removal is mandatory.** Upon accepting a remove (or a BRC-190 ban or exclusion, Section 9), every remaining member MUST discard the removed member's inbound sessions, MUST create a fresh outbound session, and MUST distribute it pairwise to the remaining members only. Until a member has rekeyed, its subsequent messages remain readable by the removed member; clients MUST finish their own rekey before their next send and MUST pause sending if rekey cannot complete. A removed recipient cannot be made to forget already received plaintext or keys. A full rekey is O(n²) pairwise messages across the group; this is the accepted cost of the sender-keys design (Section 11). - -#### 6.3 Group Metadata (0x03) - -An application-defined payload (name, avatar reference, topic), signed by an authorized member. Its schema is out of scope; the frame exists so metadata travels encrypted rather than as messagebox-visible envelope fields. - -### 7. Standalone Group Administration - -For groups not bound to a BRC-190 room: - -- The creator is the initial administrator. Administrators MAY add and remove members and MAY designate additional administrators via membership-change frames (change `0x03` add-admin, `0x04` remove-admin, same layout as 6.2). -- Any member MAY send content. Members learn the roster from add/remove frames; a client MUST accept membership changes only when the identity signature is by a current administrator. -- Two administrators removing each other concurrently is resolved by accepting the change whose carrying message was received first at each client; this document does not provide consensus on the roster, and applications requiring stronger membership agreement should bind the group to a BRC-190 room. - -### 8. Transport Mapping - -- **BRC-33 / BRC-169 messageboxes are the normative transport.** A group or control message is delivered to each member as one BRC-169 §7.2 envelope whose `content` member carries: for control messages and Profile A, the BRC-78 blob; for Profile B group messages, the Section 4.4 message bytes. The envelope's own rules (signature over canonicalized metadata excluding content, scope and toll enforcement at the recipient's messagebox) apply unchanged; nothing in this document extends §7.2. Profile B exposes its `GRP1` marker, group ID, session ID, and index to the messagebox; these allow direct correlation of members and traffic. Pairwise control frames hide those plaintext fields but still reveal communication metadata. -- Fan-out under a `toll` scope means one toll payment per member envelope, each with its own single-use quote, per BRC-169 §8.3. -- **On-chain anchoring is OPTIONAL and minimal.** The pairwise-channel prerequisite (e.g. the BRC-187 `friend` exchange) is on-chain by that protocol's own design. This document defines no on-chain group record: publishing membership on-chain makes the membership graph public forever, and the roster is already carried encrypted by Section 6. Applications wanting a durable public anchor for group existence MAY publish one (for example a Bitcoin Schema record naming only the group ID) and should weigh the permanence of that disclosure; room-bound groups get their public identity from the BRC-190 room instead. +This document defines one interoperable v1 baseline: pairwise fan-out using unmodified BRC-78. A sender signs one canonical group-content frame and encrypts identical bytes separately to every other member. The former sender-key/ratchet Profile B is informative future work only; v1 accepts no such packets and has no fallback negotiation. This reuses deployed primitives while stating static-identity forward-secrecy and post-compromise limits explicitly. -### 9. Composition with BRC-190 Rooms +## Normative baseline -A group MAY be bound at creation to a BRC-190 room by including the room identifier (`room:/`) in the group metadata frame. For a room-bound group: +The key words MUST, MUST NOT, SHOULD and MAY are interpreted as in RFC 2119. In the formulas, `||` denotes byte concatenation, `BGM1` is ASCII `42 47 4d 31`, and `\0` denotes one terminal byte `00`, never a literal backslash and digit. Roster sorting is lexicographic over the 33 encoded key bytes. Parsers MUST enforce the following rules. Integers are unsigned big-endian. Fixed fields have exactly their stated length; parsers reject trailing bytes, duplicate members, invalid UTF-8, malformed compressed secp256k1 points, signatures other than 64-byte `r||s` with `1<=r0` MUST follow exactly `k-1` and the pinned creator; the complete roster and metadata replace the prior snapshot. The creator cannot be removed. A duplicate hash is idempotent. Gaps buffer within implementation limits or report sync-needed and never skip. Two valid hashes for one epoch freeze the durable group, retain both proofs, and never choose by first-seen order. Newer acceptance stops old-epoch live sends and accepts; old frames are historical-only and rejected by default. This detects equivocation only after conflicting records meet and cannot prove a global latest head; freshness, censorship, and stale offline views remain application responsibilities. Pending or known bans pause sending until a matching creator epoch is accepted. BRC-190 room gates are policy input to creator epochs, never consensus or packet keys. -### 11. Security Considerations +### Content -This section states the construction's known limits plainly; a client or product description that claims more than the following is wrong. +The unsigned content frame `M` is `BGM1 || 0x10 || groupID[32] || epoch:u32 || epochHash[32] || sender[33] || messageID[16] || contentType:u8 || bodyLength:u32 || body`; append a sender identity signature under `BSV-GROUP-CONTENT\0`. Type 1 is UTF-8 text; type 2 is opaque application bytes; all other types reject. The message ID is CSPRNG-generated once per logical message. Encrypt the identical signed bytes separately with unmodified BRC-78 to each other roster member; the sender stores a local copy and never sends to non-members. Accept only when BRC-78 sender equals `sender`, its recipient is local, both keys are in the accepted epoch, and epoch/hash is exactly the current live epoch. Retries resend identical signed bytes, though each outer BRC-78 wrapper may be fresh. Deduplicate for the group lifetime by `(groupID,sender,messageID)`; different unsigned content bytes authenticated by a valid signature under that key are equivocation and is rejected/reported. No cross-sender total order exists. -- **Ratchet-state-only compromise.** A state at index `i` derives keys from `i` onward; recovering earlier keys requires an earlier retained state or another source. Erasing old states reduces this exposure. This is a conditional property, not an end-to-end forward-secrecy guarantee. -- **No post-compromise security.** An adversary who obtains a sender key can passively read that sender's session indefinitely until rotation; the hash ratchet never self-heals. This is a documented property of the sender-keys design (Megolm "Limitations"; RFC 9420 §1; Balbás–Collins–Gajland, ASIACRYPT 2023). A fresh session can contain a ratchet-state-only compromise if it is distributed over an uncompromised channel. Rotation is recommended and removal rekey is mandatory; neither repairs a compromised identity/distribution channel by itself. -- **Rekey is O(n²) and mandatory on removal.** Until every member rekeys, a removed member reads the non-rekeyed senders. Sending pauses until the local sender has rekeyed, as required by 6.2. -- **Replay.** Without the 4.6 index tracking, any observer of ciphertext can replay old messages; tracking is therefore REQUIRED, not advisory. -- **The pairwise channel is load-bearing.** Session shares inherit every weakness of the channel that carries them, including unknown-key-share ambiguities; the identity signature inside the share (6.1) is the defense this document adds, and receivers who skip its verification void the group's authenticity entirely. Note that BRC-78's AEAD alone provides no third-party-attributable sender authentication — both parties can compute the key — which is why the specified control frames carry identity signatures. These signatures alone do not fix replay, membership-state agreement, or recipient-context binding. -- **No transcript consistency.** Nothing guarantees all members saw the same messages; per-sender indices guarantee only per-sender gap detection. -- **No deniability claim.** Content messages are signed by session keys (linkable to identities via signed session shares held by members); this document makes no off-the-record claim. -- **Forward secrecy of the fielded stack.** An attacker who records BRC-78 session-share ciphertexts and later obtains an involved identity private key can derive the wrapping keys, recover earlier shared ratchet states, and decrypt historical group content. Erasing local ratchet states does not erase those recorded shares. Rotation does not heal a compromised identity key when replacement sessions use the same compromised distribution channel. A forward-secure, authenticated pairwise key exchange and identity recovery procedure are prerequisites for a stronger claim. -- **secp256k1 ECDH implementation note.** Deriving shared secrets on secp256k1 has a known footgun: some libraries hash the ECDH output by default while others return the raw x-coordinate. BRC-42's shared secret is the ECDH point per that specification and its test vectors; implementations MUST validate against the BRC-42 vectors rather than assuming their library's default (compare the analogous warning in Nostr NIP-44, which specifies the unhashed x-coordinate and warns against libsecp256k1's hashing default). -- **Legacy cohort.** Deployed applications carrying Electrum-ECIES ("BIE1", AES-128-CBC, deterministic KDF-derived IV) direct messages and BIP32-path friend keys are a distinct legacy cohort. This document does not accept those constructions: new group and pairwise traffic under this specification uses BRC-42/43/78 exclusively, and clients supporting legacy history SHOULD treat it as read-only. +Control records (genesis and epochs) travel inside normal BRC-78 and may be forwarded; signatures still bind the pinned creator and membership is checked before join. BRC-78 shared-key authentication is not public authorship proof. Authorization uses cryptographic identity keys only; BAP, social, room, and labels are external associations. -### 12. Future Work +BRC-78 interoperation is pinned to version bytes `42 42 10 33` and invoice `2-message encryption-` plus base64(keyID32), matching [EncryptedMessage.ts at 4090d4b](https://github.com/bsv-blockchain/ts-sdk/blob/4090d4ba0b42440a9bbe4869ee594aed2c91ab33/src/messages/EncryptedMessage.ts). The reversed byte example in BRC-78 prose is not used here. -- **MLS (RFC 9420) migration** following the Marmot precedent from the Nostr ecosystem: standard-ciphersuite MLS with separate per-group keys, the secp256k1 identity key binding identity via the MLS credential — sidestepping the absence of a standardized secp256k1 ciphersuite. TreeKEM would replace this document's O(n²) rekey with O(log n) and add true post-compromise security. -- **DHKEM-secp256k1** (CFRG draft-wahby-cfrg-hpke-kem-secp256k1) as the path to a native ciphersuite. -- **Schnorr (BIP340) signatures** for session and identity signatures as ecosystem tooling adopts them. -- Post-quantum hybrid ratchets (Signal Double Ratchet revision 4's documented variants). -- Ratcheted 1:1 channels (a ported Double Ratchet as a BRC-78 successor), which would require an explicitly versioned integration and new interoperability tests. -- Broadcast channels (owner-published feeds) — a different threat model, likely a sibling BRC. -- Metadata privacy (sealed-sender analogs over BRC-33). +## Security and scope -## Primitive Test Vector +Member roster history is encrypted in transit but visible to members; transport leaks routing and fan-out. Removing a member stops local old-epoch sends/accepts but cannot revoke delivered plaintext or stale/offline peers; synchronize the complete authenticated chain before resuming after restore. Static identities mean recorded BRC-78 ciphertext can become readable after identity-key compromise. No blanket forward-secrecy or post-compromise guarantee is claimed. MLS and a sender-key ratchet offer different tradeoffs and remain future extensions. This draft needs independent implementations, adversarial review, and live freshness UX integration before production. -For test-only initial ratchet bytes `00 01 02 ... 7f`, index 0, the Section 4.3 HKDF yields: +## Acceptance state and recovery -```text -KEY_0 = 95806396f223a283858523238b9011ac6253130bd897e405601470da3e28fb79 -IV_0 = d204791ff5204d1cc5fa2e59 -SHA256(R_256) = adb1b84e4b3c23c90fff5ded6e8fcfcd0a6210fd6c5c9d2cf463a93c2b7f3985 -``` +A participant MUST retain the accepted signed genesis, all accepted signed epoch records, their unsigned hashes, and its highest accepted epoch durably. Genesis acceptance is an explicit trust decision, outside decryption: a valid signature under an unknown creator key does not make a group trusted. A joining member MUST authenticate the expected creator and accept the specific group ID, then verify the complete signed chain through its joining epoch. Forwarded control records are allowed, but their outer BRC-78 sender does not replace the creator signature. Control records MUST be encrypted only for their intended existing member or joining recipient; former members are not entitled to new control delivery. -The last line follows 256 consecutive Section 4.2 advances from that initial state. These values were computed with Python standard-library HMAC/SHA-256. They test only ratchet/KDF arithmetic, not encryption, signatures, wire compatibility, or group-state security. Complete protocol vectors remain a blocker below. +A creator MUST serialize and durably record epoch updates before distribution. The uint32 epoch MUST NOT wrap; exhaustion requires a new group. On uncertain restore, a creator MUST NOT issue more epochs for the old group until it can establish its prior history without reusing an epoch. Loss or compromise of the immutable administrator requires a new group with explicit acceptance; neither BAP rotation nor a room label silently replaces that key. -## Open Design Requirements +A received update must have the group's ID and the pinned creator's valid signature. For an unseen epoch, accept only the next epoch with the current hash as predecessor. A gap reports synchronization required (bounded buffering is optional); it never authorizes skipping records. A byte-identical unsigned hash at an already accepted epoch is idempotent. A different structurally valid, creator-signed record for any already accepted epoch MUST durably freeze the group, including when that older fork is discovered after later epochs. Retain both signed records as evidence and stop sending and live acceptance. No automatic tie-break or branch selection is specified. A malicious creator can keep partitions apart; fork detection is not consensus or prevention of equivocation. -This draft is not a complete independent-implementation specification until the following are resolved: +For content, a future epoch requires synchronization; a stale epoch or wrong current epoch hash is rejected for live use. Applications MAY offer separate historical display after validating the corresponding old roster and signatures, but MUST NOT label those packets as newly accepted live messages or rewind authority. The receiver's local identity and the sender must both be members of the packet's authenticated epoch, with outer sender/recipient bindings checked as specified above. -1. **Authenticated bootstrap and membership epochs.** Define the group-creation record, trusted creator discovery, initial roster, profile negotiation, and a signed monotonic epoch/predecessor binding for every control and content frame. Current add/remove frames have no replay identifier or ordering field: replaying a prior authorized add can re-admit a removed member. A local replay cache alone does not solve delivery to a new client. Room membership does not supply this missing cryptographic state protocol automatically. -2. **Conflicting administration.** First-received administrator changes can permanently split the roster and leak content to a member another client considers removed. Define recovery or authenticated agreement before claiming a shared membership boundary. -3. **Signed metadata and context.** Specify metadata serialization, signature position/preimage, immutable room binding, recipient binding for session shares, and rules preventing rollback or cross-context forwarding. The present metadata placeholder is insufficient for interoperable authentication. -4. **Profile transitions.** Define capability negotiation, retirement of older sessions, and logical-message deduplication across pairwise fallback and sender-key retransmission. Do not assume arrival order establishes these semantics. -5. **Portable vectors and review.** Supply complete byte vectors for both profiles, BRC-78 wrapping, session shares, low-S signatures, GCM AAD/tag verification, ratchet boundaries at 256/65536/16777216, malformed inputs, replay, crash recovery, joins and removals. Independently reproduce them in two implementations and obtain adversarial cryptographic review. Primitive tests alone do not validate this composition. +Deduplication state MUST be durable for the group lifetime. A receiver treats a repeated `(groupID, sender, messageID)` with the same unsigned content bytes as a duplicate; different unsigned content bytes authenticated by a valid signature under that tuple are sender equivocation and MUST be rejected and reported. If storage or synchronization limits prevent retaining the required history or deduplication state, report an explicit incomplete/limit condition and pause live operation rather than silently forgetting replay protection. Message IDs are freshly generated 16-byte CSPRNG values, retained for retries of the same signed packet. -No deployed wire compatibility is claimed for this new design. The exact fields and versions may need revision to close these blockers. +After offline operation or restore, synchronize before resuming. Even a complete authenticated chain cannot prove it is the globally newest chain: a relay can withhold a newer revocation. Accepting a new epoch enforces removal locally, not instantaneously across stale peers. Known pending bans pause sending until a corresponding creator-signed roster update is accepted. An application requiring a stronger online freshness guarantee needs an explicit additional service/trust policy; v1 makes no such guarantee. -## Reproducible primitive checks +## Comparison and remaining work -Run [the portable Python 3 verifier](./media/0188-verify.py). These checks reproduce the stated primitive vectors; they do not establish complete protocol or independent implementation interoperability. +This baseline has one public administrator key, explicit signed membership snapshots and one BRC-78 envelope per recipient. The inner frame gives all recipients identical publicly verifiable author bytes; outer encryption alone would let a shared-key recipient construct a transcript. Per-recipient delivery costs grow with roster size, bounded here to 256 members. -## Implementations +A future sender-key extension could reduce message fan-out but would need a separate specification for key distribution, nonce allocation, counters, skipped messages, rotation and compromise recovery. No sender-key packets, ratchet Profile B or heuristic negotiation are accepted in v1. [MLS (RFC 9420)](https://www.rfc-editor.org/rfc/rfc9420) provides a different group key-management design with forward-secrecy and post-compromise properties under its assumptions; v1 deliberately reuses BRC-78 and does not claim those properties. [BRC-186](https://github.com/opldotdev/BRCs/blob/codex/brc-186-bap/peer-to-peer/0186.md) identities and [BRC-190](../apps/0190.md) gates can inform creator policy, but do not change the cryptographic authorization rules here. -None yet; this specification precedes its first implementation deliberately. Matrix Megolm and other sender-key protocols are prior art; none is an implementation of this wire format or control protocol. A cryptographic review of this document by a qualified adversarial reviewer is a prerequisite to any implementation carrying real conversations. +## Conformance fixtures -## References +[Signed fixtures](./media/0188-vectors.json) contain exact genesis, add-member, remove-member, metadata-update and content frames, with unsigned hashes. [The portable verifier](./media/0188-verify.py) parses those bytes and checks low-S ECDSA, roster transitions and duplicate behavior. It exercises 33 rejection cases, including old-epoch forks, changed-message-ID payloads, removed senders, wrong outer identities, malformed keys and bounds. Run `python3 peer-to-peer/media/0188-verify.py` from the repository root. The keys 1, 2 and 3 and test-only nonce construction are public synthetic fixtures and MUST NEVER be used in production. -- Megolm group ratchet (design template; Apache-2.0): https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md -- Signal Double Ratchet, revision 4: https://signal.org/docs/specifications/doubleratchet/ -- Signal X3DH: https://signal.org/docs/specifications/x3dh/ -- MLS, RFC 9420: https://datatracker.ietf.org/doc/html/rfc9420 -- Marmot Protocol (MLS over Nostr): https://github.com/marmot-protocol/marmot -- Balbás, Collins, Gajland — "WhatsUpp with Sender Keys? Analysis, Improvements and Security Proofs" (ASIACRYPT 2023): https://eprint.iacr.org/2023/1385 ; companion analysis: https://arxiv.org/abs/2301.07045 -- Nostr NIP-44 (secp256k1 payload reference and ECDH caveats): https://github.com/nostr-protocol/nips/blob/master/44.md -- HKDF, RFC 5869: https://www.rfc-editor.org/rfc/rfc5869 -- DHKEM-secp256k1 CFRG draft: https://datatracker.ietf.org/doc/draft-wahby-cfrg-hpke-kem-secp256k1/ -- [BRC-42: BSV Key Derivation Scheme](../key-derivation/0042.md) · [BRC-43: Security Levels, Protocol IDs, Key IDs and Counterparties](../key-derivation/0043.md) -- [BRC-78: Serialization Format for Portable Encrypted Messages](./0078.md) -- [BRC-33: PeerServ Message Relay Interface](./0033.md) · [BRC-169: Universal Handle Addressing and Resolution for the Metanet](./0169.md) -- [BRC-190: Access Gates for Metanet Rooms](../apps/0190.md) · [BRC-186: BAP — Bitcoin Attestation Protocol](https://github.com/opldotdev/BRCs/blob/codex/brc-186-bap/peer-to-peer/0186.md) · [BRC-187: Bitcoin Schema — Social Data Types](https://github.com/opldotdev/BRCs/blob/codex/brc-187-social-schema/apps/0187.md) +The five positive signatures were cross-checked with `@bsv/sdk` 2.4.2. These fixtures test inner framing and acceptance logic; they assume the caller supplies authenticated BRC-78 identities and do not implement outer encryption, persistent storage or a complete group client. Independent clients with encrypted transport round-trips, adversarial review, and freshness/recovery UX are still required before production. Passing fixtures is not a cryptographic security audit. diff --git a/peer-to-peer/media/0188-vectors.json b/peer-to-peer/media/0188-vectors.json new file mode 100644 index 00000000..383a8ccd --- /dev/null +++ b/peer-to-peer/media/0188-vectors.json @@ -0,0 +1,25 @@ +{ + "warning": "PUBLIC SYNTHETIC PRIVATE KEYS 1,2,3; NEVER FOR PRODUCTION", + "vectors": { + "genesis": { + "frame": "42474d31010279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00020279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee50000000464656d6f6f450cc8d639f21a4068e234c1a4cfde836f2af0bef8155d90f459dd3caa1bd13cc1cad69b6c698fcb28cd3f931d9076f6bb7958fe3cf9a5aaabf7612a6bbb8a", + "unsignedHash": "e57547be3deab18377c043482c12147760b2335df0dd23c444a60e71dbb02ce6" + }, + "add": { + "frame": "42474d3102e57547be3deab18377c043482c12147760b2335df0dd23c444a60e71dbb02ce600000001e57547be3deab18377c043482c12147760b2335df0dd23c444a60e71dbb02ce600030279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee502f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f90000000464656d6f600aa44199771c4a0a448f51a7df1fb759d62783f45c0e2359804325e0b7b8235af9247955409850f288b023e97bc884a9789b0fad67e1ada79473fa47ba3d83", + "unsignedHash": "7e31375bcc12ec446f3ea71d584d2ab6ae80c783a1b3ed26aa82cffce0527c8e" + }, + "remove": { + "frame": "42474d3102e57547be3deab18377c043482c12147760b2335df0dd23c444a60e71dbb02ce6000000027e31375bcc12ec446f3ea71d584d2ab6ae80c783a1b3ed26aa82cffce0527c8e00020279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee50000000464656d6f20c222d8b31a6d36ef8fef6fdc5729ecc032ba38809baf15ee4365f340ee816e3f04422bbef35bb0842b7e51b7fed65a6b5b5ad33afa7f0023b008860194f522", + "unsignedHash": "88c1610c4590f5e2911e6d4fa7d578979941fb80f06cf05f6c431758a1c399cb" + }, + "metadata": { + "frame": "42474d3102e57547be3deab18377c043482c12147760b2335df0dd23c444a60e71dbb02ce60000000388c1610c4590f5e2911e6d4fa7d578979941fb80f06cf05f6c431758a1c399cb00020279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179802c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5000000096e6577206c6162656c51914228c48335fef3d327bee8c0edcce6a0c6849a1efda94b7aa94fc056e04018f580922c58619a4a0af40630e415e3dc17e69527c36003b7b1c598b0d22176", + "unsignedHash": "d41b06a77f3f04710df8af0db868ff772b346d393d9a79491c3d5d2c2b5ec7b2" + }, + "content": { + "frame": "42474d3110e57547be3deab18377c043482c12147760b2335df0dd23c444a60e71dbb02ce6000000017e31375bcc12ec446f3ea71d584d2ab6ae80c783a1b3ed26aa82cffce0527c8e0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179830313233343536373839616263646566010000000568656c6c6f3815e46a6ca4253dec0f181ff26e45202c6d9a23cd3589a2d04f57b55bb640df098e2220389028ea75d8a33537f88bab5892ec527eeb31149b75fcfb70391466", + "unsignedHash": "ef650bd97a870d5600e41a0d7026ce6a9f5fce59f4c77d3a136977a22435e422" + } + } +} diff --git a/peer-to-peer/media/0188-verify.py b/peer-to-peer/media/0188-verify.py index 57837ad6..678ea4a1 100644 --- a/peer-to-peer/media/0188-verify.py +++ b/peer-to-peer/media/0188-verify.py @@ -1,18 +1,169 @@ #!/usr/bin/env python3 -"""BRC-513 primitive-only fixture: NOT a security or interoperability test.""" -import hashlib -import hmac -H = lambda k, m: hmac.new(k, m, hashlib.sha256).digest() -r = bytes(range(128)) -prk = H(bytes(32), r) -t1 = H(prk, b'bsv group message keys\x01') -t2 = H(prk, t1 + b'bsv group message keys\x02') -assert t1.hex() == '95806396f223a283858523238b9011ac6253130bd897e405601470da3e28fb79' -assert t2[:12].hex() == 'd204791ff5204d1cc5fa2e59' -parts = [r[i:i+32] for i in range(0,128,32)] -for i in range(1,257): - j = 0 if i % 2**24 == 0 else 1 if i % 2**16 == 0 else 2 if i % 256 == 0 else 3 - old = parts[j] - parts[j:] = [H(old, bytes([k])) for k in range(j,4)] -assert hashlib.sha256(b''.join(parts)).hexdigest() == 'adb1b84e4b3c23c90fff5ded6e8fcfcd0a6210fd6c5c9d2cf463a93c2b7f3985' -print('PASS: group HKDF and ratchet 256 boundary only; no AEAD/wire/membership assertions') +"""BRC-188 v1 deterministic vectors and verifier; keys 1,2,3 are synthetic.""" +import hashlib,json,os +P=2**256-2**32-977;N=0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141;G=(55066263022277343669578718895168534326250603453777594175500187360389116729240,32670510020758816978083085130507043184471273380659243275938904335757337482424) +def A(a,b): + if a is None:return b + if b is None:return a + if a[0]==b[0] and (a[1]+b[1])%P==0:return None + m=((3*a[0]*a[0])*pow(2*a[1],-1,P) if a==b else (b[1]-a[1])*pow(b[0]-a[0],-1,P))%P;x=(m*m-a[0]-b[0])%P;return x,(m*(a[0]-x)-a[1])%P +def M(k,a=G): + r=None + while k: + if k&1:r=A(r,a) + a=A(a,a);k>>=1 + return r +def pub(k):x,y=M(k);return bytes([2+y%2])+x.to_bytes(32,'big') +def sig(b,k): + z=int.from_bytes(hashlib.sha256(b).digest(),'big');q=(z+k)%N or 1;r=M(q)[0]%N;s=pow(q,-1,N)*(z+r*k)%N;s=min(s,N-s);return r.to_bytes(32,'big')+s.to_bytes(32,'big') +def ver(b,s,key): + if len(s)!=64:return False + r,x=int.from_bytes(s[:32],'big'),int.from_bytes(s[32:],'big') + if not(0=P or Y*Y%P!=(X**3+7)%P:return False + Y=Y if Y%2==key[0]%2 else P-Y;z=int.from_bytes(hashlib.sha256(b).digest(),'big');q=A(M(z*pow(x,-1,N)%N),M(r*pow(x,-1,N)%N,(X,Y)));return q is not None and q[0]%N==r + +# Test-only signing uses public synthetic keys and a deliberately simple nonce. +# NEVER use this signer or its nonce construction with real private keys. +D={1:b'BSV-GROUP-GENESIS\0',2:b'BSV-GROUP-EPOCH\0',16:b'BSV-GROUP-CONTENT\0'} +def H(b):return hashlib.sha256(b).digest() +def u(n,size):return n.to_bytes(size,'big') +def seal(b,k):return b+sig(D[b[4]]+b,k) +def roster(keys,meta=b'demo'): + return u(len(keys),2)+b''.join(keys)+u(len(meta),4)+meta +def genesis(keys):return seal(b'BGM1\x01'+pub(1)+bytes(range(32))+roster(keys),1) +def epoch(gid,n,prev,keys,meta=b'demo',signer=1): + return seal(b'BGM1\x02'+gid+u(n,4)+prev+roster(keys,meta),signer) +def content(gid,n,prev,signer=1,body=b'hello',ident=b'0123456789abcdef',typ=1): + return seal(b'BGM1\x10'+gid+u(n,4)+prev+pub(signer)+ident+bytes([typ])+u(len(body),4)+body,signer) +class Reader: + def __init__(self,b):self.b=b;self.i=0 + def take(self,n): + if self.i+n>len(self.b):raise ValueError('truncated') + v=self.b[self.i:self.i+n];self.i+=n;return v + def num(self,n):return int.from_bytes(self.take(n),'big') +def key_ok(k): + if len(k)!=33 or k[0] not in (2,3):raise ValueError('key') + x=int.from_bytes(k[1:],'big');y=pow((x*x*x+7)%P,(P+1)//4,P) + if x>=P or y*y%P!=(x*x*x+7)%P:raise ValueError('point') +def parse(frame,creator=None): + if len(frame)>1048576 or len(frame)<69:raise ValueError('size') + r=Reader(frame[:-64]);o={} + if r.take(4)!=b'BGM1':raise ValueError('magic') + t=r.num(1);o['type']=t + if t not in D:raise ValueError('type') + if t==1: + o['creator']=r.take(33);key_ok(o['creator']);r.take(32) + o['gid']=H(frame[:-64]);o['epoch']=0;signer=o['creator'] + else: + o['gid']=r.take(32);o['epoch']=r.num(4);o['prev']=r.take(32) + signer=creator + if t in (1,2): + n=r.num(2) + if not 1<=n<=256:raise ValueError('roster count') + keys=[r.take(33) for _ in range(n)] + for k in keys:key_ok(k) + if keys!=sorted(set(keys)) or signer not in keys:raise ValueError('roster') + o['keys']=keys;size=r.num(4) + if size>4096:raise ValueError('metadata') + o['metadata']=r.take(size).decode('utf8') + else: + signer=r.take(33);key_ok(signer);o['sender']=signer;o['id']=r.take(16) + typ=r.num(1);size=r.num(4);o['body']=r.take(size) + if typ not in (1,2):raise ValueError('content type') + if typ==1:o['body'].decode('utf8') + if r.i!=len(r.b):raise ValueError('trailing') + if signer is None or not ver(D[t]+r.b,frame[-64:],signer):raise ValueError('signature') + o['hash']=H(r.b);return o +class State: + # Models authenticated inner frames only. BRC-78 outer identities are supplied + # by a caller that has already decrypted/validated the real outer envelope. + def __init__(self,g,expected,local): + o=parse(g) + if o['creator']!=expected or local not in o['keys']:raise ValueError('join') + self.creator=expected;self.local=local;self.gid=o['gid'];self.n=0 + self.history={0:o};self.seen={};self.frozen=False + def control(self,b): + if self.frozen:raise ValueError('frozen') + o=parse(b,self.creator);n=o['epoch'] + if o['type']!=2 or o['gid']!=self.gid or n==0:raise ValueError('epoch') + if n in self.history: + if o['hash']==self.history[n]['hash']:return 'duplicate' + self.frozen=True;raise ValueError('fork frozen') + if n!=self.n+1:raise ValueError('sync needed') + if o['prev']!=self.history[self.n]['hash']:raise ValueError('predecessor') + self.history[n]=o;self.n=n;return 'accepted' + def receive(self,b,outer_sender,outer_recipient): + if self.frozen:raise ValueError('frozen') + o=parse(b,self.creator);current=self.history[self.n] + if o['type']!=16 or o['gid']!=self.gid:raise ValueError('group') + if o['epoch']>self.n:raise ValueError('sync needed') + if o['epoch']!=self.n or o['prev']!=current['hash']:raise ValueError('stale/hash') + if o['sender']!=outer_sender or outer_recipient!=self.local:raise ValueError('outer identity') + if o['sender'] not in current['keys'] or self.local not in current['keys']:raise ValueError('membership') + key=(self.gid,o['sender'],o['id']) + if key in self.seen: + if self.seen[key]==o['hash']:return 'duplicate' + raise ValueError('sender equivocation') + self.seen[key]=o['hash'];return 'accepted' +def main(): + path=os.path.join(os.path.dirname(__file__),'0188-vectors.json') + keys=[pub(i) for i in (1,2,3)];g=genesis(sorted(keys[:2]));gid=H(g[:-64]) + e1=epoch(gid,1,gid,sorted(keys));e2=epoch(gid,2,H(e1[:-64]),sorted(keys[:2]));e3=epoch(gid,3,H(e2[:-64]),sorted(keys[:2]),b'new label') + m=content(gid,1,H(e1[:-64]));fixtures={'genesis':g,'add':e1,'remove':e2,'metadata':e3,'content':m} + data={name:{'frame':b.hex(),'unsignedHash':H(b[:-64]).hex()} for name,b in fixtures.items()} + if '--generate' in __import__('sys').argv: + with open(path,'w') as f:json.dump({'warning':'PUBLIC SYNTHETIC PRIVATE KEYS 1,2,3; NEVER FOR PRODUCTION','vectors':data},f,indent=2);f.write('\n') + with open(path) as f:assert json.load(f)['vectors']==data + checks=[] + def reject(name,fn): + try:fn() + except (ValueError,UnicodeError):checks.append(name);return + raise AssertionError('accepted '+name) + st=State(g,keys[0],keys[1]);assert st.control(e1)=='accepted';assert st.control(e1)=='duplicate' + assert st.receive(m,keys[0],keys[1])=='accepted';assert st.receive(m,keys[0],keys[1])=='duplicate' + # Distinct valid low-S ECDSA signatures do not change unsigned content identity. + z=int.from_bytes(H(D[16]+m[:-64]),'big');nonce=123456789;r=M(nonce)[0]%N + ss=pow(nonce,-1,N)*(z+r)%N;ss=min(ss,N-ss) + alternate=m[:-64]+u(r,32)+u(ss,32) + assert alternate!=m and st.receive(alternate,keys[0],keys[1])=='duplicate' + reject('changed message ID payload',lambda:st.receive(content(gid,1,H(e1[:-64]),body=b'other'),keys[0],keys[1])) + reject('outer sender',lambda:st.receive(m,keys[2],keys[1])) + reject('outer recipient',lambda:st.receive(m,keys[0],keys[2])) + reject('future content',lambda:st.receive(content(gid,2,H(e2[:-64])),keys[0],keys[1])) + assert st.control(e2)=='accepted' + reject('old content',lambda:st.receive(m,keys[0],keys[1])) + reject('removed sender',lambda:st.receive(content(gid,2,H(e2[:-64]),signer=3),keys[2],keys[1])) + reject('wrong epoch hash',lambda:st.receive(content(gid,2,bytes(32)),keys[0],keys[1])) + reject('wrong content group',lambda:st.receive(content(bytes(32),2,H(e2[:-64])),keys[0],keys[1])) + assert st.control(e3)=='accepted' + reject('old fork freezes',lambda:st.control(epoch(gid,1,gid,sorted(keys),b'fork')));assert st.frozen + reject('frozen sends',lambda:st.receive(content(gid,3,H(e3[:-64])),keys[0],keys[1])) + fresh=lambda:State(g,keys[0],keys[1]) + reject('wrong expected creator',lambda:State(g,keys[1],keys[1])) + reject('nonmember join',lambda:State(g,keys[0],keys[2])) + reject('wrong creator signature',lambda:fresh().control(epoch(gid,1,gid,sorted(keys),signer=2))) + reject('wrong group',lambda:fresh().control(epoch(bytes(32),1,gid,sorted(keys)))) + reject('wrong predecessor',lambda:fresh().control(epoch(gid,1,bytes(32),sorted(keys)))) + reject('gap',lambda:fresh().control(e2)) + reject('epoch zero',lambda:fresh().control(epoch(gid,0,gid,sorted(keys)))) + reject('duplicate roster',lambda:parse(genesis([keys[0],keys[0]]))) + reject('unsorted roster',lambda:parse(genesis(sorted(keys[:2],reverse=True)))) + reject('creator removed',lambda:fresh().control(epoch(gid,1,gid,[keys[1]]))) + reject('empty roster',lambda:parse(genesis([]))) + reject('oversized roster',lambda:parse(genesis([keys[0]]*257))) + reject('invalid point',lambda:parse(genesis(sorted([keys[0],b'\x02'+b'\xff'*32])))) + reject('trailing bytes',lambda:parse(seal(g[:-64]+b'junk',1))) + reject('truncated',lambda:parse(g[:-1])) + reject('wrong domain',lambda:parse(g[:-64]+sig(D[2]+g[:-64],1))) + reject('bad signature',lambda:parse(g[:-64]+bytes(64))) + reject('high S',lambda:parse(g[:-32]+u(N-int.from_bytes(g[-32:],'big'),32))) + reject('metadata UTF8',lambda:fresh().control(epoch(gid,1,gid,sorted(keys),b'\xff'))) + reject('metadata limit',lambda:fresh().control(epoch(gid,1,gid,sorted(keys),b'a'*4097))) + reject('body UTF8',lambda:parse(content(gid,1,gid,body=b'\xff'))) + reject('content type',lambda:parse(content(gid,1,gid,typ=3))) + reject('frame limit',lambda:parse(content(gid,1,gid,body=b'a'*1048576))) + assert parse(content(gid,1,gid,body=b'\xff',typ=2))['body']==b'\xff' + print('PASS: 5 signed fixtures, add/remove/metadata/replay transitions, '+str(len(checks))+' exercised rejections; no outer encryption or chain verification') +if __name__=='__main__':main() From 654e2ed4800c5f19d168538c0dc4ce32cada05c3 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Fri, 4 Sep 2026 18:19:47 -0400 Subject: [PATCH 3/4] docs(groups): align indexes with the BRC-78 baseline title --- README.md | 2 +- SUMMARY.md | 2 +- peer-to-peer/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 478caa16..df6c65b6 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ BRC | Standard 174 | [Consensus-Unique Name Tokens — Identity Binding and Verified Resolution](./tokens/0174.md) 176 | [BSV-21 — Validity Proofs](./tokens/0176.md) 177 | [Wallet-Enforced Expiry for `noSend` Actions](./wallet/0177.md) -188 | [Encrypted Group Messaging over Type-42 Key Derivation](./peer-to-peer/0188.md) +188 | [Encrypted Group Messaging over BRC-78](./peer-to-peer/0188.md) 190 | [Access Gates for Metanet Rooms](./apps/0190.md) 210 | [Derived Collectibles](./apps/0210.md) 218 | [Chat-Native Command Grammar for the Metanet](./apps/0218.md) diff --git a/SUMMARY.md b/SUMMARY.md index 8cba4b73..be98703d 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -182,7 +182,7 @@ * [Single-Use Signed Proofs for Request Authentication](./peer-to-peer/0138.md) * [Fountain-Coded Air-Gap Transport for Arbitrary Payloads](./peer-to-peer/0141.md) * [Universal Handle Addressing and Resolution for the Metanet](./peer-to-peer/0169.md) -* [Encrypted Group Messaging over Type-42 Key Derivation](./peer-to-peer/0188.md) +* [Encrypted Group Messaging over BRC-78](./peer-to-peer/0188.md) * [Animated-QR Air-Gap Transport for Arbitrary Payloads (TKQR1)](./peer-to-peer/0225.md) * [Keyed Content and Conditional Key Release](./peer-to-peer/0369.md) diff --git a/peer-to-peer/README.md b/peer-to-peer/README.md index 77dd8dbc..84442906 100644 --- a/peer-to-peer/README.md +++ b/peer-to-peer/README.md @@ -19,6 +19,6 @@ BRC | Standard 138 | [Single-Use Signed Proofs for Request Authentication](./0138.md) 141 | [Fountain-Coded Air-Gap Transport for Arbitrary Payloads](./0141.md) 169 | [Universal Handle Addressing and Resolution for the Metanet](./0169.md) -188 | [Encrypted Group Messaging over Type-42 Key Derivation](./0188.md) +188 | [Encrypted Group Messaging over BRC-78](./0188.md) 225 | [Animated-QR Air-Gap Transport for Arbitrary Payloads (TKQR1)](./0225.md) 369 | [Keyed Content and Conditional Key Release](./0369.md) From 40155429a2930c7447d5fbd01bc9dcd76c8079a7 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Fri, 4 Sep 2026 19:45:10 -0400 Subject: [PATCH 4/4] docs: focus proposal on specification and review examples --- peer-to-peer/0188.md | 19 +++- peer-to-peer/media/0188-verify.py | 169 ------------------------------ 2 files changed, 14 insertions(+), 174 deletions(-) delete mode 100644 peer-to-peer/media/0188-verify.py diff --git a/peer-to-peer/0188.md b/peer-to-peer/0188.md index b06e5a6c..c48aac3b 100644 --- a/peer-to-peer/0188.md +++ b/peer-to-peer/0188.md @@ -1,6 +1,6 @@ # BRC-188: Encrypted Group Messaging over BRC-78 -**Authors:** Luke Rohenaz; contributors Kurt Wuckert Jr., David Case, Michael Boyd, and Dan Wagner. **Status: non-production design draft.** +**Authors:** Luke Rohenaz; contributors Kurt Wuckert Jr., David Case, Michael Boyd, and Dan Wagner. **Status: Draft request for comments.** ## Abstract @@ -28,7 +28,7 @@ BRC-78 interoperation is pinned to version bytes `42 42 10 33` and invoice `2-me ## Security and scope -Member roster history is encrypted in transit but visible to members; transport leaks routing and fan-out. Removing a member stops local old-epoch sends/accepts but cannot revoke delivered plaintext or stale/offline peers; synchronize the complete authenticated chain before resuming after restore. Static identities mean recorded BRC-78 ciphertext can become readable after identity-key compromise. No blanket forward-secrecy or post-compromise guarantee is claimed. MLS and a sender-key ratchet offer different tradeoffs and remain future extensions. This draft needs independent implementations, adversarial review, and live freshness UX integration before production. +Member roster history is encrypted in transit but visible to members; transport leaks routing and fan-out. Removing a member stops local old-epoch sends/accepts but cannot revoke delivered plaintext or stale/offline peers; synchronize the complete authenticated chain before resuming after restore. Static identities mean recorded BRC-78 ciphertext can become readable after identity-key compromise. No blanket forward-secrecy or post-compromise guarantee is claimed. MLS and a sender-key ratchet offer different tradeoffs and remain future extensions. Production deployment requires application-specific security and recovery review; those implementation activities are outside this proposal. ## Acceptance state and recovery @@ -44,7 +44,7 @@ Deduplication state MUST be durable for the group lifetime. A receiver treats a After offline operation or restore, synchronize before resuming. Even a complete authenticated chain cannot prove it is the globally newest chain: a relay can withhold a newer revocation. Accepting a new epoch enforces removal locally, not instantaneously across stale peers. Known pending bans pause sending until a corresponding creator-signed roster update is accepted. An application requiring a stronger online freshness guarantee needs an explicit additional service/trust policy; v1 makes no such guarantee. -## Comparison and remaining work +## Design tradeoffs and extensions This baseline has one public administrator key, explicit signed membership snapshots and one BRC-78 envelope per recipient. The inner frame gives all recipients identical publicly verifiable author bytes; outer encryption alone would let a shared-key recipient construct a transcript. Per-recipient delivery costs grow with roster size, bounded here to 256 members. @@ -52,6 +52,15 @@ A future sender-key extension could reduce message fan-out but would need a sepa ## Conformance fixtures -[Signed fixtures](./media/0188-vectors.json) contain exact genesis, add-member, remove-member, metadata-update and content frames, with unsigned hashes. [The portable verifier](./media/0188-verify.py) parses those bytes and checks low-S ECDSA, roster transitions and duplicate behavior. It exercises 33 rejection cases, including old-epoch forks, changed-message-ID payloads, removed senders, wrong outer identities, malformed keys and bounds. Run `python3 peer-to-peer/media/0188-verify.py` from the repository root. The keys 1, 2 and 3 and test-only nonce construction are public synthetic fixtures and MUST NEVER be used in production. +[Signed examples](./media/0188-vectors.json) contain exact genesis, add-member, +remove-member, metadata-update and content frames, with unsigned hashes and +expected identities. These illustrate the inner wire format; BRC-78 defines +the outer encryption. Keys 1, 2 and 3 are public synthetic example keys and +MUST NEVER be used in production. The five example signatures were checked +with `@bsv/sdk` 2.4.2; this is not a security-audit or deployed-client claim. -The five positive signatures were cross-checked with `@bsv/sdk` 2.4.2. These fixtures test inner framing and acceptance logic; they assume the caller supplies authenticated BRC-78 identities and do not implement outer encryption, persistent storage or a complete group client. Independent clients with encrypted transport round-trips, adversarial review, and freshness/recovery UX are still required before production. Passing fixtures is not a cryptographic security audit. +## Implementations + +No complete implementation of this group protocol is claimed. This is a new +proposal built on BRC-78; clients and transport integrations belong in their +respective implementation repositories. diff --git a/peer-to-peer/media/0188-verify.py b/peer-to-peer/media/0188-verify.py deleted file mode 100644 index 678ea4a1..00000000 --- a/peer-to-peer/media/0188-verify.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -"""BRC-188 v1 deterministic vectors and verifier; keys 1,2,3 are synthetic.""" -import hashlib,json,os -P=2**256-2**32-977;N=0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141;G=(55066263022277343669578718895168534326250603453777594175500187360389116729240,32670510020758816978083085130507043184471273380659243275938904335757337482424) -def A(a,b): - if a is None:return b - if b is None:return a - if a[0]==b[0] and (a[1]+b[1])%P==0:return None - m=((3*a[0]*a[0])*pow(2*a[1],-1,P) if a==b else (b[1]-a[1])*pow(b[0]-a[0],-1,P))%P;x=(m*m-a[0]-b[0])%P;return x,(m*(a[0]-x)-a[1])%P -def M(k,a=G): - r=None - while k: - if k&1:r=A(r,a) - a=A(a,a);k>>=1 - return r -def pub(k):x,y=M(k);return bytes([2+y%2])+x.to_bytes(32,'big') -def sig(b,k): - z=int.from_bytes(hashlib.sha256(b).digest(),'big');q=(z+k)%N or 1;r=M(q)[0]%N;s=pow(q,-1,N)*(z+r*k)%N;s=min(s,N-s);return r.to_bytes(32,'big')+s.to_bytes(32,'big') -def ver(b,s,key): - if len(s)!=64:return False - r,x=int.from_bytes(s[:32],'big'),int.from_bytes(s[32:],'big') - if not(0=P or Y*Y%P!=(X**3+7)%P:return False - Y=Y if Y%2==key[0]%2 else P-Y;z=int.from_bytes(hashlib.sha256(b).digest(),'big');q=A(M(z*pow(x,-1,N)%N),M(r*pow(x,-1,N)%N,(X,Y)));return q is not None and q[0]%N==r - -# Test-only signing uses public synthetic keys and a deliberately simple nonce. -# NEVER use this signer or its nonce construction with real private keys. -D={1:b'BSV-GROUP-GENESIS\0',2:b'BSV-GROUP-EPOCH\0',16:b'BSV-GROUP-CONTENT\0'} -def H(b):return hashlib.sha256(b).digest() -def u(n,size):return n.to_bytes(size,'big') -def seal(b,k):return b+sig(D[b[4]]+b,k) -def roster(keys,meta=b'demo'): - return u(len(keys),2)+b''.join(keys)+u(len(meta),4)+meta -def genesis(keys):return seal(b'BGM1\x01'+pub(1)+bytes(range(32))+roster(keys),1) -def epoch(gid,n,prev,keys,meta=b'demo',signer=1): - return seal(b'BGM1\x02'+gid+u(n,4)+prev+roster(keys,meta),signer) -def content(gid,n,prev,signer=1,body=b'hello',ident=b'0123456789abcdef',typ=1): - return seal(b'BGM1\x10'+gid+u(n,4)+prev+pub(signer)+ident+bytes([typ])+u(len(body),4)+body,signer) -class Reader: - def __init__(self,b):self.b=b;self.i=0 - def take(self,n): - if self.i+n>len(self.b):raise ValueError('truncated') - v=self.b[self.i:self.i+n];self.i+=n;return v - def num(self,n):return int.from_bytes(self.take(n),'big') -def key_ok(k): - if len(k)!=33 or k[0] not in (2,3):raise ValueError('key') - x=int.from_bytes(k[1:],'big');y=pow((x*x*x+7)%P,(P+1)//4,P) - if x>=P or y*y%P!=(x*x*x+7)%P:raise ValueError('point') -def parse(frame,creator=None): - if len(frame)>1048576 or len(frame)<69:raise ValueError('size') - r=Reader(frame[:-64]);o={} - if r.take(4)!=b'BGM1':raise ValueError('magic') - t=r.num(1);o['type']=t - if t not in D:raise ValueError('type') - if t==1: - o['creator']=r.take(33);key_ok(o['creator']);r.take(32) - o['gid']=H(frame[:-64]);o['epoch']=0;signer=o['creator'] - else: - o['gid']=r.take(32);o['epoch']=r.num(4);o['prev']=r.take(32) - signer=creator - if t in (1,2): - n=r.num(2) - if not 1<=n<=256:raise ValueError('roster count') - keys=[r.take(33) for _ in range(n)] - for k in keys:key_ok(k) - if keys!=sorted(set(keys)) or signer not in keys:raise ValueError('roster') - o['keys']=keys;size=r.num(4) - if size>4096:raise ValueError('metadata') - o['metadata']=r.take(size).decode('utf8') - else: - signer=r.take(33);key_ok(signer);o['sender']=signer;o['id']=r.take(16) - typ=r.num(1);size=r.num(4);o['body']=r.take(size) - if typ not in (1,2):raise ValueError('content type') - if typ==1:o['body'].decode('utf8') - if r.i!=len(r.b):raise ValueError('trailing') - if signer is None or not ver(D[t]+r.b,frame[-64:],signer):raise ValueError('signature') - o['hash']=H(r.b);return o -class State: - # Models authenticated inner frames only. BRC-78 outer identities are supplied - # by a caller that has already decrypted/validated the real outer envelope. - def __init__(self,g,expected,local): - o=parse(g) - if o['creator']!=expected or local not in o['keys']:raise ValueError('join') - self.creator=expected;self.local=local;self.gid=o['gid'];self.n=0 - self.history={0:o};self.seen={};self.frozen=False - def control(self,b): - if self.frozen:raise ValueError('frozen') - o=parse(b,self.creator);n=o['epoch'] - if o['type']!=2 or o['gid']!=self.gid or n==0:raise ValueError('epoch') - if n in self.history: - if o['hash']==self.history[n]['hash']:return 'duplicate' - self.frozen=True;raise ValueError('fork frozen') - if n!=self.n+1:raise ValueError('sync needed') - if o['prev']!=self.history[self.n]['hash']:raise ValueError('predecessor') - self.history[n]=o;self.n=n;return 'accepted' - def receive(self,b,outer_sender,outer_recipient): - if self.frozen:raise ValueError('frozen') - o=parse(b,self.creator);current=self.history[self.n] - if o['type']!=16 or o['gid']!=self.gid:raise ValueError('group') - if o['epoch']>self.n:raise ValueError('sync needed') - if o['epoch']!=self.n or o['prev']!=current['hash']:raise ValueError('stale/hash') - if o['sender']!=outer_sender or outer_recipient!=self.local:raise ValueError('outer identity') - if o['sender'] not in current['keys'] or self.local not in current['keys']:raise ValueError('membership') - key=(self.gid,o['sender'],o['id']) - if key in self.seen: - if self.seen[key]==o['hash']:return 'duplicate' - raise ValueError('sender equivocation') - self.seen[key]=o['hash'];return 'accepted' -def main(): - path=os.path.join(os.path.dirname(__file__),'0188-vectors.json') - keys=[pub(i) for i in (1,2,3)];g=genesis(sorted(keys[:2]));gid=H(g[:-64]) - e1=epoch(gid,1,gid,sorted(keys));e2=epoch(gid,2,H(e1[:-64]),sorted(keys[:2]));e3=epoch(gid,3,H(e2[:-64]),sorted(keys[:2]),b'new label') - m=content(gid,1,H(e1[:-64]));fixtures={'genesis':g,'add':e1,'remove':e2,'metadata':e3,'content':m} - data={name:{'frame':b.hex(),'unsignedHash':H(b[:-64]).hex()} for name,b in fixtures.items()} - if '--generate' in __import__('sys').argv: - with open(path,'w') as f:json.dump({'warning':'PUBLIC SYNTHETIC PRIVATE KEYS 1,2,3; NEVER FOR PRODUCTION','vectors':data},f,indent=2);f.write('\n') - with open(path) as f:assert json.load(f)['vectors']==data - checks=[] - def reject(name,fn): - try:fn() - except (ValueError,UnicodeError):checks.append(name);return - raise AssertionError('accepted '+name) - st=State(g,keys[0],keys[1]);assert st.control(e1)=='accepted';assert st.control(e1)=='duplicate' - assert st.receive(m,keys[0],keys[1])=='accepted';assert st.receive(m,keys[0],keys[1])=='duplicate' - # Distinct valid low-S ECDSA signatures do not change unsigned content identity. - z=int.from_bytes(H(D[16]+m[:-64]),'big');nonce=123456789;r=M(nonce)[0]%N - ss=pow(nonce,-1,N)*(z+r)%N;ss=min(ss,N-ss) - alternate=m[:-64]+u(r,32)+u(ss,32) - assert alternate!=m and st.receive(alternate,keys[0],keys[1])=='duplicate' - reject('changed message ID payload',lambda:st.receive(content(gid,1,H(e1[:-64]),body=b'other'),keys[0],keys[1])) - reject('outer sender',lambda:st.receive(m,keys[2],keys[1])) - reject('outer recipient',lambda:st.receive(m,keys[0],keys[2])) - reject('future content',lambda:st.receive(content(gid,2,H(e2[:-64])),keys[0],keys[1])) - assert st.control(e2)=='accepted' - reject('old content',lambda:st.receive(m,keys[0],keys[1])) - reject('removed sender',lambda:st.receive(content(gid,2,H(e2[:-64]),signer=3),keys[2],keys[1])) - reject('wrong epoch hash',lambda:st.receive(content(gid,2,bytes(32)),keys[0],keys[1])) - reject('wrong content group',lambda:st.receive(content(bytes(32),2,H(e2[:-64])),keys[0],keys[1])) - assert st.control(e3)=='accepted' - reject('old fork freezes',lambda:st.control(epoch(gid,1,gid,sorted(keys),b'fork')));assert st.frozen - reject('frozen sends',lambda:st.receive(content(gid,3,H(e3[:-64])),keys[0],keys[1])) - fresh=lambda:State(g,keys[0],keys[1]) - reject('wrong expected creator',lambda:State(g,keys[1],keys[1])) - reject('nonmember join',lambda:State(g,keys[0],keys[2])) - reject('wrong creator signature',lambda:fresh().control(epoch(gid,1,gid,sorted(keys),signer=2))) - reject('wrong group',lambda:fresh().control(epoch(bytes(32),1,gid,sorted(keys)))) - reject('wrong predecessor',lambda:fresh().control(epoch(gid,1,bytes(32),sorted(keys)))) - reject('gap',lambda:fresh().control(e2)) - reject('epoch zero',lambda:fresh().control(epoch(gid,0,gid,sorted(keys)))) - reject('duplicate roster',lambda:parse(genesis([keys[0],keys[0]]))) - reject('unsorted roster',lambda:parse(genesis(sorted(keys[:2],reverse=True)))) - reject('creator removed',lambda:fresh().control(epoch(gid,1,gid,[keys[1]]))) - reject('empty roster',lambda:parse(genesis([]))) - reject('oversized roster',lambda:parse(genesis([keys[0]]*257))) - reject('invalid point',lambda:parse(genesis(sorted([keys[0],b'\x02'+b'\xff'*32])))) - reject('trailing bytes',lambda:parse(seal(g[:-64]+b'junk',1))) - reject('truncated',lambda:parse(g[:-1])) - reject('wrong domain',lambda:parse(g[:-64]+sig(D[2]+g[:-64],1))) - reject('bad signature',lambda:parse(g[:-64]+bytes(64))) - reject('high S',lambda:parse(g[:-32]+u(N-int.from_bytes(g[-32:],'big'),32))) - reject('metadata UTF8',lambda:fresh().control(epoch(gid,1,gid,sorted(keys),b'\xff'))) - reject('metadata limit',lambda:fresh().control(epoch(gid,1,gid,sorted(keys),b'a'*4097))) - reject('body UTF8',lambda:parse(content(gid,1,gid,body=b'\xff'))) - reject('content type',lambda:parse(content(gid,1,gid,typ=3))) - reject('frame limit',lambda:parse(content(gid,1,gid,body=b'a'*1048576))) - assert parse(content(gid,1,gid,body=b'\xff',typ=2))['body']==b'\xff' - print('PASS: 5 signed fixtures, add/remove/metadata/replay transitions, '+str(len(checks))+' exercised rejections; no outer encryption or chain verification') -if __name__=='__main__':main()