Cryptography
Overview
The project uses Ethereum-compatible cryptographic primitives for identity and authentication. Message integrity is handled by BLAKE3 hashing. The node's P2P identity uses secp256k1 keys.
Algorithms
| Purpose | Algorithm | Library |
|---|---|---|
| Signature | ECDSA secp256k1 | secp256k1 (bitcoin-style) + k256 + subtle |
| Message hash (auth) | Keccak-256 | tiny-keccak |
| Address derivation | Keccak-256 of uncompressed pubkey | tiny-keccak + k256 |
| Message ID | BLAKE3 | blake3 |
| DM chat ID | BLAKE3 | blake3 |
| Group chat ID | BLAKE3 | blake3 |
| Merkle tree hashing | BLAKE3 | blake3 |
| GossipSub message ID | BLAKE3 | blake3 |
Address Derivation (Ethereum-style)
Private Key (32 bytes, secp256k1)
|
v
Public Key (uncompressed, 65 bytes: 0x04 || X || Y)
|
v (drop first byte 0x04)
Keccak256(pubkey[1..65]) --> 32 bytes
|
v (take last 20 bytes)
Address = hash[12..32] --> 20 bytes
This is identical to Ethereum address derivation. Addresses are displayed as 0x-prefixed hex strings (42 characters).
ECDSA Signature Verification
Implementation in crates/crypto/src/lib.rs:
#![allow(unused)] fn main() { fn verify_sig_recover( string_to_sign: &str, sig_hex: &str, // 65 bytes: r[32] || s[32] || v[1] claimed_addr_hex: &str, // 0x-prefixed 20-byte address ) -> Result<(), String> }
Process
- Compute
msg_hash = Keccak256(string_to_sign.as_bytes()) - Parse 65-byte signature:
r || s || v- Normalize
v: ifv >= 27, subtract 27 (Ethereum convention)
- Normalize
- Try ECDSA recovery with provided
v, then with1 - v(tolerance for incorrect recovery ID) - For each recovered public key:
- Convert to uncompressed SEC1 format (65 bytes)
- Compute
addr = Keccak256(pubkey[1..])[12..32] - Compare with claimed address using constant-time comparison (
subtle::ConstantTimeEq) to prevent timing side-channel attacks
- Return
Ok(())if any attempt matches, otherwiseErr
Signature Format
[r: 32 bytes][s: 32 bytes][v: 1 byte]
Total: 65 bytes, hex-encoded in X-Sig header (130 hex chars)
v values: 0 or 1 (or 27/28 in Ethereum convention -- both accepted).
Canonical String-to-Sign
Built by crates/api/src/utils.rs::canonical::build_string_to_sign:
p2p-mes-v1
METHOD:{METHOD}
PATH:{path}
QUERY:{canonical_query}
BODY:{canonical_body}
TS:{timestamp_ms}
NODE:{peer_id_base58}
Canonicalization
Query parameters:
- Parse URL query string
- Sort pairs by (key, value)
- Percent-encode each key and value (NON_ALPHANUMERIC charset)
- Join with
&:key1=value1&key2=value2
JSON body:
- Parse JSON
- Flatten to dot-notation:
{"a": {"b": 1}}->a.b=1 - Arrays use
[]suffix:{"items": [1,2]}->items[]=1&items[]=2 - An array element that is itself an object (or a nested array) is not
flattened further: the element becomes a single pair whose value is its
compact JSON serialization -- no whitespace, object keys sorted
alphabetically at every depth:
{"ops": [{"b": 1, "a": "x"}]}->ops[]={"a":"x","b":1}. The JSON string is then percent-encoded like any other value (step 6).POST /groups/{chat_id}/opsis the main endpoint this applies to; the group-ops entry in the publishedtest-vectors.jsongives a byte-exact reference - Sort pairs by (key, value)
- Percent-encode and join
Form body: same as query params
Binary/other: raw={hex_of_body}
Empty body/query: empty string (no pairs)
Public Utility Functions
#![allow(unused)] fn main() { /// Keccak-256 hash of arbitrary bytes. fn keccak256(bytes: &[u8]) -> [u8; 32] /// Parse hex string (with or without 0x prefix) into 20-byte address. /// Returns None for invalid hex or wrong length. fn parse_addr20(s: &str) -> Option<[u8; 20]> }
Message ID (msg_id)
Deterministic hash for idempotent message storage. The node computes it --
the hlc stamp is assigned server-side by the originating node, so clients
never compute msg_id themselves (they receive it in the HTTP response):
#![allow(unused)] fn main() { fn compute_msg_id(chat: &[u8], sender: &[u8], hlc: HlcTimestamp, text: &str) -> [u8; 32] { BLAKE3(chat || sender || hlc.to_packed().to_be_bytes() || text.as_bytes()) } }
hlc.to_packed() is a u64 (48 bits physical milliseconds + 16 bits logical)
hashed as 8 big-endian bytes -- the same shape as the legacy ts: u64 it
replaced, so the digest stays 32 bytes. Same inputs always produce the same
msg_id across all nodes. See PROTOCOL.md and TYPES.md for
HlcTimestamp.
DM Chat ID
Deterministic chat identifier for direct messages:
#![allow(unused)] fn main() { fn dm_chat_id(a: [u8; 20], b: [u8; 20]) -> [u8; 32] { let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; BLAKE3("p2p-mes:chat:dm:v1:" || lo || hi) } }
Both participants compute the same chat_id regardless of who sends first. No server coordination or membership storage needed.
Node Identity
Nodes use secp256k1 keypairs for libp2p identity:
- Private key: 32 bytes, specified in TOML config as hex
- PeerId: derived from public key, displayed as Base58
- CLI command to derive PeerId:
cargo run -p node -- peer-id 0x<64hex>
Group Chat ID
#![allow(unused)] fn main() { fn group_chat_id(creator: [u8; 20], nonce: &[u8; 16], title: &str) -> [u8; 32] { BLAKE3("p2p-mes:chat:group:v2:" || creator || nonce || title_utf8) } }
The client generates a random 16-byte nonce, picks an optional group
title, computes the chat_id, and includes both nonce and title in
the Create op request. Every verifier (HTTP API, MPSC handler, gossip
receivers, sync piggyback) recomputes the hash and rejects a mismatch.
The title is hashed as raw UTF-8 bytes with no Unicode normalization -- signer and verifier must agree on the exact byte sequence. An unnamed group hashes zero title bytes. Because the creator's membership signature covers the chat_id and the chat_id commits to the title, the title is authenticated transitively and is immutable for the lifetime of the group.
See PROTOCOL.md for the full compound creation flow.
Membership Signature Verification
#![allow(unused)] fn main() { fn verify_membership_sig( chat_id: &[u8; 32], target: &[u8; 20], op_type: u8, // Add=0, Remove=1, Create=2 sig_bytes: &[u8], // 65 bytes: r[32] || s[32] || v[1] ) -> Result<[u8; 20], String> }
Canonical 53-byte message: chat_id[32] || target[20] || op_type[1]
Hash: keccak256(canonical_message). Recover admin address from ECDSA signature. Tolerant to both raw v (0/1) and Ethereum v (27/28).
Known Limitation: ts/HLC is not cryptographically authoritative
The canonical message above deliberately excludes the HLC stamp (and
the legacy ts: u64 it replaced). Clients have no access to
node-level HLC state, so the originating API node stamps hlc on
behalf of the client immediately after signature verification. This
keeps the client signature compact and means clients never specify
time.
Consequence: a peer that controls a gossip pipe could rewrite the
hlc field on a relayed MembershipOp (or any PutMessage /
PutIdentity) without invalidating the client signature, biasing
CRDT decisions. The current trust model treats API nodes as trusted
relays, so we accept this gap; CRDT-level merging is the only
defence today.
Closing this gap is a separate workstream (node-level identity + keypair, sign every op at gossip publish), explicitly out of scope for the HLC migration. Until that lands, do not deploy nodes you do not trust to honestly forward HLC stamps.
Merkle Tree Hashing
See SYNC.md for details. Uses BLAKE3 for:
- Level-1 nodes:
BLAKE3(256 leaf values concatenated) - Root:
BLAKE3(256 L1 values concatenated) - Leaf values are XOR accumulators (not hashes)