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'scrypto_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 bysodium_malloc/sodium_freerather 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. Explicitsodium_mlockrecords lock success; destruction wipes and unlocks only the owned allocation, and moves transfer ownership without copying bytes. - Bounded libsodium-backed
secure_random_bytesandsecure_random_hexhelpers, 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 intoold_verify_keysand 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 installscrypto::RefusingEd25519Provider(everysignandverifyfails closed), and main refuses anysign_requestframe 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_kxhandshake via a domain-separated labelmerovingian:ipc-channel-auth:1(distinct from the v3/v4 access-token HMAC labels). Each side MACs the other's ephemeralcrypto_kxpublic key (and its role) withcrypto_authbefore deriving session keys, so a local process that reaches the inheritedAF_UNIXfd 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:WorkerPoolderives the key once (crypto::derive_ipc_auth_key) andWorkerSupervisor::spawn_and_connecthands the worker only those derived bytes over a second inherited pipe fd (--ipc-key-fd); the worker (federation_worker::read_ipc_auth_key) never callscrypto::load_master_key_materialor sees the master key material itself. The auth key is wiped withsodium_memzeroafter the handshake on both ends (crypto::IpcAuthKey's own destructor), and the worker additionally clearssecurity.secrets.master_key_filefrom 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 peeraccess_tokenandAuthorization/X-Matrixheaders are stripped from thefed_requestframe and never cross IPC, so a compromised worker cannot harvest or replay peer homeserver credentials. The outboundAuthorizationheader 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 astd::string.make_federation_signaturetakes astd::span<std::uint8_t const>fromSecretBuffer::bytes()so the signing primitive still sees a span, butOutboundCall::secret_key,DispatchWorkerConfig::secret_key, andperform_sync_outbound_callnow own a zeroisingSecretBuffercopy made while the caller holdsruntime.mutex. This removes thestd::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_boxauthenticated 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 lockedcore::SecretBufferthrough 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,TokenHmacKeyandIpcAuthKeystore their 32-byte values incrypto::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-timesodium_mlock/munlockcycle on a fresh 4 KiB scratch buffer twice — narrowing that to a validated open plusfstat()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 concurrentmlockchurn that could exhaustRLIMIT_MEMLOCKunder load. - Master-key lock failure is fatal. The secure-file reader rejects an
unlocked
SecretBufferbefore returning material tocrypto::load_master_key_material(); the loader also has a defensive lock check. The reader's failure currently reaches the genericmaster_key.load_faileddiagnostic in thecryptomodule, not a distinct lock-failure event. Check file admission and the service'sRLIMIT_MEMLOCK; raise the locked-memory allowance or provisionCAP_IPC_LOCKwhere appropriate. The previous warn-and-continue policy is superseded by ADR-0168. - Locking guarantees depend on the owning type and call site. A general
SecretBufferrecords a failed lock without failing construction. Master-key loading andSecureKeyBytesenforce locking; decrypted signing secrets and their runtimeSecretBuffercopies do not all checkis_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_keyselects the usable key with the greatestvalid_until_ts, skipping the legacyed25519:autosentinel (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/serveradvertises 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_keyguarantees the runtime signing provider holds the key it returns, rebuilding the provider if it does not. The preferred key is always treated as active bycollect_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/serverdocument carries a refresh deadline (one hour); past it the fast path re-publishes rather than serving a document whose advertisedvalid_until_tshas elapsed. - The federation dispatch worker snapshots the signing identity when it is
constructed, so
rotate_server_signing_keyhands it the new key directly. The refresh cannot live inwire_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_keypairon 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_keysderive 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_requestframe (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¶
- Add dependency review documentation for the selected crypto provider.
- Add a concrete production provider behind the
RandomSourceinterface (include/merovingian/crypto/random.hpp) — the Ed25519 half is already done (RuntimeEd25519Provider,RuntimeMultiKeyEd25519Provider, wired insrc/homeserver/runtime.cpp); production code paths callsecure_random_bytes/secure_random_hexdirectly rather than going through aRandomSourceimplementation. - Add audit events for signing-key lifecycle changes.
- Investigate hardware-backed key storage (HSM or key vault) for operator-managed deployments that cannot rely on a filesystem master key.