Client Signature Protocol

Client Signature Protocol

Device keys, room-scoped contextual keys, hybrid ECDSA P-256 plus ML-DSA-65 signatures, and canonical JSON.

Client Signature Protocol

#

This layer proves to other clients, in a cryptographically verifiable way, that a payload was produced by the holder of a specific device key and recovery-word derived identity. The server relays these signatures and public keys but never possesses the private key material.

Identity key material

The client maintains the following key material:

KeyAlgorithmPurposeWhere it appears
Device signing keyECDSA P-256Signs each end-to-end encrypted message.Public key attached to every message envelope.
ML-DSA-65 keyML-DSA-65 (FIPS 204)Post-quantum component of hybrid signatures.Public key in the prekey bundle.
ML-KEM-768 keyML-KEM-768 (FIPS 203)Encapsulation key for sealing friend envelopes and the anti-abuse challenge.Public key in the prekey bundle.
Master secret32 bytes, derivedRoot for contextual signing keys and the roster key.Never leaves the client.
Contextual keypairECDSA P-256, derived per roomSigns the inner friend envelope in a room-specific domain.Public key appears only inside sealed envelopes.

The device signing key and ML-DSA-65 key are generated by the client and exported as JWK (for P-256) and hex (for ML-DSA). The ML-KEM-768 key is generated by the audited ml-kem / @noble/post-quantum library.

Master secret derivation

The master secret is derived from the 12 recovery words. It is never stored; it is recomputed on demand whenever a friend envelope must be sealed or the roster must be decrypted.

phrase      = join(recovery_words, " ")
    seed        = PBKDF2-SHA256(password = phrase,
                                salt     = "qxphantom:master",
                                iterations = 100000,
                                dkLen    = 256 bits)
    masterSecret = HKDF-SHA256(ikm  = seed,
                               salt = empty,
                               info = "qxp-master",
                               len  = 32 bytes)

If the recovery words are not present in the client state, the master secret cannot be derived and the QXP-PHANTOM signing and roster operations are unavailable.

Contextual keypair derivation

A room-scoped ECDSA P-256 keypair is derived from the master secret and a room id. This separates signing identities across rooms so that a public key used in one room cannot be linked to the public key used in another room.

salt          = SHA256(UTF-8(roomId))
    seed          = HKDF-SHA256(ikm  = masterSecret,
                                salt = salt,
                                info = "qxphantom:ctx:v1",
                                len  = 32 bytes)
    d             = (bigint(seed) mod (order(P-256) - 1)) + 1
    publicKey     = P-256 point from scalar d

The scalar d and the public point are used to build a P-256 JWK. Only the public key is ever serialized into an envelope.

Hybrid signatures

QXP-PHANTOM requires both signatures to validate. An adversary must break both schemes to forge a payload. The two schemes are:

  • ECDSA P-256 over the canonical byte string, using either the device key (prekey bundle) or a contextual key (inner envelope).
  • ML-DSA-65 over the same canonical byte string, using the device key.

The ECDSA signature is the raw r || s form (64 bytes, IEEE P1363) encoded as b64url. The ML-DSA-65 signature is 3309 bytes encoded as hex.

Prekey bundle signature

The canonical bytes are the sorted, compact JSON of the bundle with the two signature fields removed. The ECDSA component is produced by the device ECDSA P-256 key, and the ML-DSA component by the device ML-DSA-65 key.

Inner envelope signature

The canonical bytes are the sorted, compact JSON of the inner envelope with the hybridSig field removed. The ECDSA component is produced by the contextual key derived for the room, and the ML-DSA component by the device ML-DSA-65 key.

Canonical serialization

All signed payloads use the same canonicalization contract, implemented identically in services/phantom_crypto.rs (Rust) and crypto/phantom.ts (TypeScript):

  • Object keys are sorted recursively.
  • Arrays keep their order.
  • Scalars are serialized with JSON rules.
  • No whitespace is inserted.

The canonicalDeviceSigningKey helper in crypto/e2ee.ts is a separate, narrower canonicalization used only to compare the public key inside an encrypted message envelope.

Trust model

  • The master secret, the ML-DSA secret key, the ML-KEM secret key, and the contextual private keys never leave the client.
  • The device ECDSA public key, the ML-DSA public key, and the ML-KEM public key are shared through the published prekey bundle.
  • The contextual public key is revealed only inside an envelope that has already been sealed to the recipient's ML-KEM key. The server cannot read it.
  • The server relays and validates the hybrid signatures on the prekey bundle during publication, but it does not possess the private keys and cannot forge an envelope.

The endpoint device is the security boundary. If the device is compromised, all of this key material is exposed.