Files
quicproquo/docs/src/cryptography/identity-keys.md
Christian Nennemann 2e081ead8e chore: rename quicproquo → quicprochat in docs, Docker, CI, and packaging
Rename all project references from quicproquo/qpq to quicprochat/qpc
across documentation, Docker configuration, CI workflows, packaging
scripts, operational configs, and build tooling.

- Docker: crate paths, binary names, user/group, data dirs, env vars
- CI: workflow crate references, binary names, artifact names
- Docs: all markdown files under docs/, SDK READMEs, book.toml
- Packaging: OpenWrt Makefile, init script, UCI config (file renames)
- Scripts: justfile, dev-shell, screenshot, cross-compile, ai_team
- Operations: Prometheus config, alert rules, Grafana dashboard
- Config: .env.example (QPQ_* → QPC_*), CODEOWNERS paths
- Top-level: README, CONTRIBUTING, ROADMAP, CLAUDE.md
2026-03-21 19:14:06 +01:00

5.9 KiB

Ed25519 Identity Keys

The Ed25519 identity keypair is the long-term cryptographic identity of a quicprochat client. It is generated once, persisted across sessions, and used for MLS credential signing, Authentication Service registration, and delivery queue addressing.

Source: crates/quicprochat-core/src/identity.rs

Structure

The IdentityKeypair struct holds two fields:

pub struct IdentityKeypair {
    /// Raw 32-byte private seed -- zeroized on drop.
    seed: Zeroizing<[u8; 32]>,
    /// Corresponding 32-byte public verifying key.
    verifying: VerifyingKey,
}
Field Type Size Secret?
seed Zeroizing<[u8; 32]> 32 bytes Yes -- zeroized on drop
verifying ed25519_dalek::VerifyingKey 32 bytes No -- public

The private seed is stored as raw bytes wrapped in Zeroizing<[u8; 32]> rather than directly as a SigningKey. This design choice avoids a conflict with ed25519-dalek's own Zeroize implementation: the Zeroizing<T> wrapper requires T: DefaultIsZeroes, which [u8; 32] satisfies (being Copy + Default) but SigningKey does not.

Key Generation

A fresh identity keypair is generated from the OS CSPRNG (OsRng) via ed25519-dalek:

use quicprochat_core::identity::IdentityKeypair;

let identity = IdentityKeypair::generate();
// The signing key seed is generated from OsRng (getrandom on Linux).
// The verifying key is derived from the seed automatically.

Internally, generate() calls SigningKey::generate(&mut OsRng), extracts the 32-byte seed with to_bytes(), wraps it in Zeroizing, and derives the VerifyingKey:

pub fn generate() -> Self {
    use rand::rngs::OsRng;
    let signing = SigningKey::generate(&mut OsRng);
    let verifying = signing.verifying_key();
    let seed = Zeroizing::new(signing.to_bytes());
    Self { seed, verifying }
}

Fingerprint Computation

The fingerprint is a SHA-256 digest of the raw 32-byte Ed25519 public key. It serves as a compact, collision-resistant identifier for logging and protocol indexing:

pub fn fingerprint(&self) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(self.verifying.to_bytes());
    hasher.finalize().into()
}

The Debug implementation uses the first 4 bytes of the fingerprint as a human-readable prefix:

// Output example:
// IdentityKeypair { fingerprint: "a1b2c3d4...", .. }

This ensures the private seed is never accidentally printed to logs.

Zeroization

The 32-byte private seed is wrapped in Zeroizing<[u8; 32]> from the zeroize crate. When the IdentityKeypair struct is dropped, the Zeroizing wrapper overwrites the seed bytes with zeros before deallocation. This mitigates the risk of key material lingering in memory after the struct is no longer needed.

Key points about the zeroization strategy:

  • On drop: The seed is overwritten with zeros automatically.
  • Serialization: seed_bytes() returns a plain [u8; 32] copy for persistence. The caller is responsible for securely handling this copy.
  • Reconstruction: from_seed(seed) wraps the provided bytes in a fresh Zeroizing immediately.
  • No Clone/Copy: IdentityKeypair does not implement Clone or Copy, preventing accidental duplication of secret material.

See Key Lifecycle and Zeroization for the full lifecycle of this key type.

Role in MLS

The IdentityKeypair implements the openmls_traits::signatures::Signer trait, allowing it to be passed directly to KeyPackage::builder().build(...):

impl Signer for IdentityKeypair {
    fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, MlsError> {
        let sk = self.signing_key();
        let sig: ed25519_dalek::Signature = sk.sign(payload);
        Ok(sig.to_bytes().to_vec())
    }

    fn signature_scheme(&self) -> SignatureScheme {
        SignatureScheme::ED25519
    }
}

This integration means IdentityKeypair:

  1. Signs MLS Commits, Proposals, and KeyPackages with Ed25519.
  2. Is embedded in BasicCredential as the raw 32-byte public key bytes.
  3. Provides the signature_key field in CredentialWithKey used throughout the GroupMember lifecycle.

The MLS ciphersuite (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) mandates Ed25519 for signing, making the IdentityKeypair the natural fit.

Role in the Authentication Service

The Ed25519 public key bytes (public_key_bytes()) are used as the identityKey in auth.capnp RPC calls. The Authentication Service stores KeyPackages indexed by this key, and the Delivery Service routes messages to queues indexed by the same key.

Serialization

IdentityKeypair implements Serialize and Deserialize (serde) by serializing only the 32-byte seed. On deserialization, from_seed() is called to reconstruct the verifying key:

impl Serialize for IdentityKeypair {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where S: serde::Serializer,
    {
        serializer.serialize_bytes(&self.seed[..])
    }
}

impl<'de> Deserialize<'de> for IdentityKeypair {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where D: serde::Deserializer<'de>,
    {
        let bytes: Vec<u8> = serde::Deserialize::deserialize(deserializer)?;
        let seed: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?;
        Ok(IdentityKeypair::from_seed(seed))
    }
}

This means the state file contains only the 32-byte seed, and the verifying key is deterministically re-derived on load.