Cryptographic Primitives

Cryptographic Primitives

Exact primitives reference: hashes, KDFs, Argon2id, BIP39, ECDSA, ML-DSA-65, ML-KEM-768, VDF, CAPTCHA and Privacy Pass.

Cryptographic Primitives

#

Reference for the exact primitives, derivations, constants, and wire formats used by the server and the client.

Hash and KDF conventions

NameDefinition
SHA256(x)Raw SHA-256 digest of bytes x.
HKDF(m, salt, info, len)HKDF-SHA256 (RFC 5869).
fp(pk)SHA256(raw pk bytes), lowercase hex, 64 characters.
hexLowercase hexadecimal, no prefix.
b64urlBase64url without padding.
||Byte concatenation.

Canonical JSON

Signed payloads are serialized deterministically:

  • Object keys sorted recursively.
  • Arrays preserved in order.
  • Scalars serialized with JSON rules.
  • No whitespace.

This contract is implemented in services/phantom_crypto.rs::canonical_json and crypto/phantom.ts::canonicalJson. The two implementations are verified against each other with a cross-language test vector.

Argon2id

Passwords and recovery phrases are hashed with Argon2id using the default argon2 crate parameters. Verification uses the same parameters. A fixed dummy hash is used for constant-time failure when a username or recovery phrase does not exist.

BIP39 recovery words

Recovery words are generated with bip39, 16 bytes of entropy, English wordlist, which yields 12 words. The phrase is normalized by splitting on whitespace, lowercasing, and rejoining with single spaces.

HMAC-SHA256

Several subsystems use HMAC-SHA256 with a per-subsystem 32 byte secret generated once per process by the OS CSPRNG. The HMAC output is lowercase hex. These secrets are never persisted, so signed tickets and challenges become invalid after a restart.

The subsystems with their own secret are:

SubsystemSecret holder
Anonymous quota (rln)core/rln.rs
VDF challengecore/vdf.rs
CAPTCHA (cap)core/cap.rs
Privacy Pass deposit tokensservices/privacy_pass.rs

ECDSA P-256

ECDSA P-256 is used for:

  • Device message signing (Web Crypto).
  • Contextual friend signing (Web Crypto).
  • The ECDSA half of the hybrid signatures.

The signature is the raw r || s form (64 bytes, IEEE P1363), not DER. It is encoded as b64url. Verification parses the two 32 byte scalars and verifies over SHA-256.

ML-DSA-65

ML-DSA-65 (FIPS 204) is the post-quantum half of the hybrid signatures. The Rust ml-dsa crate and the @noble/post-quantum ml-dsa.js library implement it.

SizeBytes
Public key1952
Signature3309

ML-KEM-768

ML-KEM-768 (FIPS 203) is used for envelope sealing and the anti-abuse PQC challenge. The Rust ml-kem crate and the @noble/post-quantum ml-kem.js library implement it.

SizeBytes
Encapsulation key1184
Decapsulation key2400
Ciphertext1088
Shared secret32

AES-256-GCM

AES-256-GCM is used for message payloads, the inner envelope, and the roster blob. The IV is 12 random bytes and the authentication tag is 16 bytes.

Anonymous quota tokens

The rln module implements an anonymous, HMAC-based quota token rather than a zero-knowledge rate-limiting nullifier. The token is:

{ "epoch": 0, "ticket": "<hex>", "signature": "<hex>" }
epoch        = floor(now_ms / 15000)
    ticket       = HMAC(secret, epoch || ":" || nonce_hex)
    signature    = HMAC(secret, epoch || ":" || ticket)

The nullifier binds a ticket to an action:

nullifier = SHA256("qxprotocol_rln_nullifier:" || ticket || ":" || epoch_be64 || ":" || action)

The server verifies the signature, checks the epoch is within a small window, compares the nullifier in constant time, and stores it with a 3 minute TTL so the same ticket cannot be spent twice for the same action.

Wesolowski Verifiable Delay Function

The VDF is a sequential squaring proof over a fixed 1024 bit RSA modulus, a hardcoded composite of two 512 bit primes. The default iteration count is 30 000 and the challenge TTL is 3 minutes.

x = HashToElement(target, salt, modulus)
    challenge = { modulus, x, t, targetHash, salt, signature, issuedAt, expiresAt }

The challenge signature covers issuedAt:t:targetHash:salt:x:expiresAt. The target hash is the first 16 hex characters of SHA256(target). The proof is verified with the Wesolowski relation:

l = HashToPrime(x, y)         // 128 bit Fiat-Shamir prime
    r = 2^t mod l
    check: (pi^l * x^r) mod N == y mod N

Post-quantum challenge

The anti-abuse challenge also includes an ML-KEM-768 encapsulation step. The server generates an ephemeral keypair, returns the encapsulation key, and later decapsulates the client ciphertext. The shared secret is discarded after decapsulation; the step currently acts as a capability gate rather than a key agreement.

{ "keyId": "<hex>", "ekHex": "<hex 1184 bytes>" }
    { "keyId": "<hex>", "ctHex": "<hex 1088 bytes>" }

The ephemeral key has a 3 minute TTL and is consumed once.

CAPTCHA token format

A CAPTCHA challenge is signed with the cap HMAC secret. Redeeming it yields a one-time cap token.

capToken = "cap." + tokenId + "." + expiresAt + "." + signature
    signature = HMAC(secret, tokenId || ":" || scope || ":" || expiresAt)

The token TTL is 5 minutes and it is consumed exactly once.

Privacy Pass deposit token format

The deposit token used by the pass gate follows the same structure:

depositToken = "pass." + tokenId + "." + expiresAt + "." + signature
    signature = HMAC(secret, tokenId || ":" || expiresAt)

The token TTL is 5 minutes.