Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PurposeAlgorithmLibrary
SignatureECDSA secp256k1secp256k1 (bitcoin-style) + k256 + subtle
Message hash (auth)Keccak-256tiny-keccak
Address derivationKeccak-256 of uncompressed pubkeytiny-keccak + k256
Message IDBLAKE3blake3
DM chat IDBLAKE3blake3
Group chat IDBLAKE3blake3
Merkle tree hashingBLAKE3blake3
GossipSub message IDBLAKE3blake3

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

  1. Compute msg_hash = Keccak256(string_to_sign.as_bytes())
  2. Parse 65-byte signature: r || s || v
    • Normalize v: if v >= 27, subtract 27 (Ethereum convention)
  3. Try ECDSA recovery with provided v, then with 1 - v (tolerance for incorrect recovery ID)
  4. 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
  5. Return Ok(()) if any attempt matches, otherwise Err

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:

  1. Parse URL query string
  2. Sort pairs by (key, value)
  3. Percent-encode each key and value (NON_ALPHANUMERIC charset)
  4. Join with &: key1=value1&key2=value2

JSON body:

  1. Parse JSON
  2. Flatten to dot-notation: {"a": {"b": 1}} -> a.b=1
  3. Arrays use [] suffix: {"items": [1,2]} -> items[]=1&items[]=2
  4. Sort pairs by (key, value)
  5. 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)