Skip to content

Cryptography boundary and signing service

This capability note describes project-owned cryptography interfaces without implementing custom cryptographic primitives.

Included now

  • Generic hashing (generic_hash.cpp) and hex/base64 encoding (encoding.cpp) wrapping libsodium's crypto_generichash/sodium_bin2hex/sodium_bin2base64.
  • Constant-time comparison boundary (fixed and variable-length inputs).
  • Variable-length comparison uses domain-separated hashing so it does not leak input length through a premature length check.
  • Runtime signing secret material uses core::SecretBuffer, backed by sodium_malloc/sodium_free rather than the shared general-purpose heap. Separate allocations occupy isolated data pages, so freeing one small secret cannot unlock another live secret that happened to share a heap page. Explicit sodium_mlock records lock success; destruction wipes and unlocks only the owned allocation, and moves transfer ownership without copying bytes.
  • Bounded libsodium-backed secure_random_bytes and secure_random_hex helpers, alongside the random-source interface.
  • Ed25519 provider interface for reviewed signing and verification integration.
  • Server signing-key store interface.
  • Server signing service that selects the active server key and delegates signing to a provider.
  • Runtime server signing-key persistence for the current local Ed25519 key.
  • Explicit server signing-key rotation (rotate_server_signing_key) that retires the active key into old_verify_keys and activates a freshly generated key.
  • Centralised runtime signing provider (HomeserverRuntime::crypto_provider) so every server signing path uses one provider instance.
  • The out-of-process federation worker never signs (ADR-0078, which supersedes the signing-oracle part of ADR-0015): its runtime starts with RuntimeStartOptions::signing_disabled, which installs crypto::RefusingEd25519Provider (every sign and verify fails closed), and main refuses any sign_request frame with an error and no signature. There is no sign-back IPC channel; the private key never enters the worker address space and the worker cannot ask main to use it.
  • Master-key-authenticated IPC key exchange (#318, key handoff redesigned 0.12.13 finding N1 / ADR-0062): a 32-byte IPC auth key (the same material used for at-rest signing-secret encryption and v4 access-token keys) authenticates the crypto_kx handshake via a domain-separated label merovingian:ipc-channel-auth:1 (distinct from the v3/v4 access-token HMAC labels). Each side MACs the other's ephemeral crypto_kx public key (and its role) with crypto_auth before deriving session keys, so a local process that reaches the inherited AF_UNIX fd without a valid auth key cannot complete the handshake or inject AEAD frames. Through 0.12.12 both processes independently derived this key from the operator master-key file, so the worker opened that file itself. As of 0.12.13 only the main process ever opens it: WorkerPool derives the key once (crypto::derive_ipc_auth_key) and WorkerSupervisor::spawn_and_connect hands the worker only those derived bytes over a second inherited pipe fd (--ipc-key-fd); the worker (federation_worker::read_ipc_auth_key) never calls crypto::load_master_key_material or sees the master key material itself. The auth key is wiped with sodium_memzero after the handshake on both ends (crypto::IpcAuthKey's own destructor), and the worker additionally clears security.secrets.master_key_file from its in-memory config copy (federation_worker::clear_master_key_file) before starting its runtime.
  • Verified-identity IPC forwarding (#323): the main process verifies the inbound peer X-Matrix signature and forwards only the verified identity (origin/key_id/sig_verified) to the worker; the raw peer access_token and Authorization/X-Matrix headers are stripped from the fed_request frame and never cross IPC, so a compromised worker cannot harvest or replay peer homeserver credentials. The outbound Authorization header that does cross IPC is our own request-bound X-Matrix signature (not a reusable peer credential); the signing secret itself never enters the worker (#317).
  • The outbound signing path keeps the server signing secret in a core::SecretBuffer, never a std::string. make_federation_signature takes a std::span<std::uint8_t const> from SecretBuffer::bytes() so the signing primitive still sees a span, but OutboundCall::secret_key, DispatchWorkerConfig::secret_key, and perform_sync_outbound_call now own a zeroising SecretBuffer copy made while the caller holds runtime.mutex. This removes the std::string{reinterpret_cast<…>(…bytes().data()…)} copies that left the key unpinned and unzeroised on the heap, and prevents a dangling span if the runtime key is rotated during the network round trip (ADR-0086).
  • Validation for Ed25519 public-key shape, signature shape, and key IDs.
  • Bounded random request-size validation.
  • Event-signing integration tests using deterministic provider doubles.
  • Cryptographically random Ed25519 keypair generation for runtime signing (replacing the previous deterministic derivation from public values).
  • secret_box authenticated encryption for at-rest signing-secret storage, using a domain-separated XSalsa20-Poly1305 key derived from a configured master key (security.secrets.master_key_file).
  • crypto::load_master_key_material() reads root bytes directly into a locked core::SecretBuffer through the descriptor-bound secret reader. The file must be a regular, effective-UID-owned, mode-0400 file; the final path component must not be a symlink. Inputs outside 32–4096 bytes, and all-zero roots, are rejected. These checks are not an entropy test: operators must provision cryptographically random material.
  • SecretBoxKey, TokenHmacKey and IpcAuthKey store their 32-byte values in crypto::SecureKeyBytes. Each copy owns its own locked sodium allocation; moves transfer ownership. Derivation and cache return paths fail closed when a locked allocation cannot be obtained. Both caches return no key on allocation failure rather than leaking an exception through their callers. The historical BLAKE2b message/key orientation and all domain labels are deliberately unchanged: silently replacing the derivation would make stored signing-key ciphertext and token hashes unusable. See ADR-0128.
  • The master key file is no longer read once per HMAC key derivation. token_hmac_keys() (src/homeserver/auth_service.cpp) derives the v3 and v4 access-token HMAC keys once and caches them, invalidated on the file's descriptor-validated file identity (path, device, inode, size, timestamps, mode and owner) so replacing the key still takes effect without a restart. Previously every authenticated request opened and read the master key file twice (once per key version) and ran the load-time sodium_mlock/munlock cycle on a fresh 4 KiB scratch buffer twice — narrowing that to a validated open plus fstat() on the steady-state path matters for a crypto boundary specifically because it shrinks how often the root secret every other key is derived from is actually materialised in process memory, and removes the concurrent mlock churn that could exhaust RLIMIT_MEMLOCK under load.
  • Master-key lock failure is fatal. The secure-file reader rejects an unlocked SecretBuffer before returning material to crypto::load_master_key_material(); the loader also has a defensive lock check. The reader's failure currently reaches the generic master_key.load_failed diagnostic in the crypto module, not a distinct lock-failure event. Check file admission and the service's RLIMIT_MEMLOCK; raise the locked-memory allowance or provision CAP_IPC_LOCK where appropriate. The previous warn-and-continue policy is superseded by ADR-0168.
  • Locking guarantees depend on the owning type and call site. A general SecretBuffer records a failed lock without failing construction. Master-key loading and SecureKeyBytes enforce locking; decrypted signing secrets and their runtime SecretBuffer copies do not all check is_locked(). They are zeroised on release, but the implementation does not establish that every signing-secret allocation is pinned. This documentation correction does not change that runtime policy.

Security posture

Local homeserver password hashing, access-token generation, access-token hashing, media deduplication hashes, and Matrix event hashes use LibSodium. Event signing and verification now flow through the Ed25519 provider interface. Runtime-created local room events use the persisted server signing-key record and fail closed if the signing key cannot be materialized. Access-token and refresh-token hashes now use a keyed BLAKE2b (token-hash:v3) construction seeded from runtime secret material; the previous unkeyed token-hash:v2 form remains accepted only for lookup compatibility with older persisted rows. - Every Ed25519 provider validates the secret key's size before handing it to libsodium. For RuntimeEd25519Provider the check is at construction — the trust boundary where forgery-capable material enters — so a SecretBuffer that is not exactly ed25519_secret_key_bytes is never retained; the malformed bytes are wiped when the by-value parameter goes out of scope. sign() keeps its own guard on the stored buffer as defence in depth, so a provider built from bad key material can never produce a signature by either route. RuntimeMultiKeyEd25519Provider already validated its stored keys. - Variable-length secret comparison does not branch on input length before computing a fixed-size digest, removing a timing side-channel on secret size. - Signing secret material is held in a core::SecretBuffer while in process memory and is zeroised when the buffer is destroyed or moved-from. The wipe is non-elidable (sodium_munlock/sodium_memzero), and custom move-ctor/move-assign transfer the mlock to the destination while wiping the source, so the secret is never duplicated in memory and never left pinned in a moved-from object. The outbound federation signing path (make_federation_signature, OutboundCall, DispatchWorkerConfig, perform_sync_outbound_call) consumes the key as a borrowing span off the SecretBuffer (or an owned SecretBuffer copy in the dispatch worker), so the key is never copied into an unpinned, unzeroised std::string to sign a request.

The runtime signing key is generated using crypto_sign_keypair, which produces a cryptographically random keypair, written directly into a core::SecretBuffer so the seed is zeroised on release and memory locking is attempted, rather than passing through a plain std::array between generation and storage. The secret seed is encrypted at rest with secret_box and a random nonce, stored as secretbox:v1:<base64(nonce || mac || ciphertext)>, and transparently decrypted on restart.

security.secrets.master_key_file is mandatory (0.12.5). There is no plaintext fallback: a server that cannot read, lock, and derive from a master key refuses to mint a signing secret, and start_runtime then refuses to start with no server signing key: .... Writing the seed as base64 with encrypted='false' handed any database-exfiltration attacker a forgery-capable federation key, which is the entire threat this column exists to defend against. Rows written by earlier versions are still read through the legacy plaintext branch, so an existing deployment keeps federating and can rotate on its own schedule — but it can no longer write a new one. The same rule applies to rotation as to first generation. An explicit rotation is available via rotate_server_signing_key: it retires the active key (setting its valid_until_ts to now so it publishes under old_verify_keys with a past expired_ts) and activates a freshly generated key.

Key lifetime

A signing key is the server's federation identity, and valid_until_ts is only the point at which peers should re-fetch the published key list — not a lease after which the server may adopt a different identity. The rules that follow from that:

  • ensure_runtime_server_signing_key selects the usable key with the greatest valid_until_ts, skipping the legacy ed25519:auto sentinel (which notary servers cached with a far-future expiry and therefore can only be retired by minting a new key id). A lapsed window does not disqualify a key.
  • Once a key is more than halfway through its advertised window, the window is rolled forward to now + 7 days and persisted. GET /_matrix/key/v2/server advertises exactly the persisted window, so the server never claims a validity it does not itself honour.
  • A new key is generated only on first boot (no usable key stored) or through an explicit rotation. Silent regeneration is prohibited: a key minted behind the running signing provider cannot sign anything (signing key not held), and no peer has ever seen it, so both local event composition and outbound federation fail at once.
  • ensure_runtime_server_signing_key guarantees the runtime signing provider holds the key it returns, rebuilding the provider if it does not. The preferred key is always treated as active by collect_active_server_signing_keys, so a window that could not be refreshed (e.g. a database write failure) degrades to peers re-fetching sooner rather than to a server that cannot sign.
  • The cached /_matrix/key/v2/server document carries a refresh deadline (one hour); past it the fast path re-publishes rather than serving a document whose advertised valid_until_ts has elapsed.
  • The federation dispatch worker snapshots the signing identity when it is constructed, so rotate_server_signing_key hands it the new key directly. The refresh cannot live in wire_federation_callbacks: that function returns early once the federation callbacks exist, so a second call after a rotation never reaches the worker.

A single key is active at a time. Comma-delimited PDUs without JSON are rejected when a signing key is available, closing the legacy-verification bypass.

The boundary provides these guarantees:

  • Application code depends on project-owned interfaces, not dependency-specific types.
  • Signing fails closed when no active usable key exists.
  • Providers returning invalid signature shapes are rejected.
  • Key IDs must be Ed25519-shaped before use.
  • Matrix event signatures are encoded as unpadded Base64 and verified against the redacted canonical payload.
  • Runtime event signatures are stored with event rows and the corresponding public signing key is persisted for local verification/key publication work.
  • Random byte requests are bounded at the interface edge.
  • Runtime signing keys are generated from system entropy, not derived from public server identity values.
  • Newly issued bearer-token digests are keyed, so a database leak no longer exposes reusable unkeyed token hashes for offline correlation.
  • New signing secrets require an available master key and are encrypted at rest. Legacy plaintext records remain a separate read-compatibility path; absence of a master key never authorizes a new plaintext record.
  • Commma-delimited PDUs without JSON body are rejected rather than bypassing Ed25519 cryptographic verification.

IPC channel key exchange

The encrypted channel between merovingian-server and merovingian-fed-worker (src/ipc/channel.cpp) uses libsodium key exchange and authenticated encryption:

  • Ephemeral key pair: each side generates a fresh crypto_kx_keypair on every connection. Neither party persists the IPC key material; compromise of the IPC key does not affect the server's Matrix signing key.
  • Key exchange: crypto_kx_server_session_keys / crypto_kx_client_session_keys derive two one-directional session keys (tx and rx) from the ephemeral pair. The exchange is authenticated — both sides must contribute valid key material or the session fails.
  • Authenticated encryption: all subsequent frames use crypto_secretstream_xchacha20poly1305 (XChaCha20-Poly1305 AEAD). Each frame is independently authenticated; a truncated or tampered frame is rejected before the plaintext is consumed.
  • Framing: [4-byte big-endian ciphertext_length][ciphertext]. The ciphertext includes the AEAD MAC overhead so the receiver can verify integrity before decrypting.
  • Isolation: client access tokens are stripped from every forwarded request. The Matrix signing key is never transmitted over the IPC channel. The worker does not load the signing secret from the database and does not ask main to sign: there is no sign_request frame (ADR-0078). Main answers one, should a compromised worker send it, with an error and no signature, inline and without taking the runtime lock. Outbound requests are signed in main and cross the channel already signed.

The IPC channel is tested by tests/unit/test_ipc_framing.cpp covering: concurrent key exchange, request/response pairing, notification delivery, timeout behaviour when no reply arrives. That the worker's provider refuses to sign is covered by tests/unit/test_crypto.cpp and tests/unit/test_server_signing_key_lifecycle.cpp (tag [worker_signing_refused]), and that main refuses a sign_request without signing or taking the runtime lock by tests/integration/test_federation_worker_flow.cpp (same tag).

Deliberately not included

These remain deferred:

  • Simultaneously-active multiple signing keys (one key is active at a time).
  • Audit-log persistence for signing-key lifecycle changes.
  • Hardware-backed key support.
  • Encrypted key storage without operator-supplied master-key material (the master key file must be provisioned and protected by the administrator).

Next starting points

  1. Add dependency review documentation for the selected crypto provider.
  2. Add a concrete production provider behind the RandomSource interface (include/merovingian/crypto/random.hpp) — the Ed25519 half is already done (RuntimeEd25519Provider, RuntimeMultiKeyEd25519Provider, wired in src/homeserver/runtime.cpp); production code paths call secure_random_bytes/secure_random_hex directly rather than going through a RandomSource implementation.
  3. Add audit events for signing-key lifecycle changes.
  4. Investigate hardware-backed key storage (HSM or key vault) for operator-managed deployments that cannot rely on a filesystem master key.