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 - 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_with_nonce(admin: [u8; 20], nonce: &[u8]) -> [u8; 32] { BLAKE3("p2p-mes:chat:group:v1:" || admin || nonce) } }
The client generates a random 16-byte nonce, computes the chat_id, and includes the nonce in the Create op request. The API verifies the derivation. 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)