Keep Node
A self-sovereign security appliance for your passwords and secrets.
Keep Node turns a small Linux box into a private security appliance. Each node runs your core security services on an encrypted vault, and the key that decrypts that vault is never held by any single device. To unlock, a quorum of devices has to cooperate. The plain consequence: stealing the box gets nothing, and a powered-on box on its own still cannot decrypt the vault.
A node bundles two services on top of that encrypted volume:
- keep-web, the Keep headless daemon: an encrypted key vault, a FROST threshold co-signer, and a NIP-46 remote-signing bunker for Nostr.
- Vaultwarden, a password-manager server that speaks the Bitwarden protocol, so you keep using stock Bitwarden clients and your normal master password.
Both run on a NixOS appliance image with reproducible builds and atomic updates. The data they hold lives on a LUKS-encrypted volume that is unsealed at boot, before Vaultwarden is allowed to start. If the volume cannot be unlocked, the password manager does not run.
The core property
The vault key is derived from a threshold quorum rather than from a single secret on the box. The Keep ecosystem splits the most sensitive material across the box, your phone, and an optional replica, so that no one device, including the box itself, ever holds enough to decrypt or to sign. Recovery is a quorum of devices, not a seed phrase you can lose.
How this relates to Keep
Keep Node is part of the broader Keep project. The
node daemon, the encrypted vault, the FROST threshold signing, and the Nostr transport
are reused from Keep’s keep-web and keep-core crates rather than rebuilt. Keep Node
adds the appliance: the NixOS image, the threshold-gated volume, the multi-node story,
and the seedless onboarding around it.
Where to start
- Architecture explains the components and how they compose, and the boot flow that unseals the vault.
- Threshold Unlock is the centerpiece: how a quorum derives the volume key with a threshold Oblivious PRF, without any party reconstructing the key.
- Security covers the threat model, the trust assumptions, and how to report a vulnerability.
- Attribution credits the open-source work Keep Node builds on.
The software is MIT licensed. Accuracy and restraint matter here more than marketing, so the chapters that follow describe what the system actually does and are careful to name the boundaries of what it protects.
Architecture
Keep Node is a NixOS appliance that composes a small set of services on top of a threshold-gated encrypted volume. This chapter describes those components, the roles the devices play, and the boot flow that brings everything up in the right order.
The appliance
The OS base is NixOS, with reproducible builds, atomic updates, and rollback. The
appliance is assembled from a few NixOS modules under nixos/, wired together by
keep-node.nix:
vaultwarden.nix, the password-manager service.keep-web.nix, the Keep headless daemon.frost-gate.nix, the encrypted-volume gate that unseals the vault (TPM seal, or the opt-in threshold-OPRF quorum).vault-replication.nix, multi-node HA: the shared JWT signing key, Litestream DB streaming, attachment/Send file replication, a replication-lag health signal, and crash-then-promote failover.mesh.nix, the encrypted node-to-node transport (nostr-vpn’snvpn, boringtun userspace WireGuard) that replication rides between nodes.wisp.nix, the opt-in on-box Nostr relay (keepNode.wisp), bound to the mesh interface, that the threshold-OPRF quorum and (opt-in) relay-based peer discovery coordinate over.measured-boot.nix, opt-in Lanzaboote UKI boot so the seal binds a real measured-boot PCR.ingress.nix, an opt-in TLS reverse proxy with brute-force protection for direct HTTPS access.
The whole image boots and is verified in a NixOS VM test with no hardware required, which
is also the canonical way to try it (nix flake check).
Components
Keep Node (NixOS appliance)
+-------------------------------------------------------------------+
| |
| keep-web daemon Vaultwarden |
| (127.0.0.1:8080) (127.0.0.1:8222) |
| - encrypted vault - Bitwarden-protocol server |
| - FROST co-signer - signups disabled |
| - NIP-46 bunker - data dir on the gated volume |
| | | |
| | | (starts only once unsealed) |
| v v |
| +----------------------------------------------------------+ |
| | frost-gate: LUKS-encrypted vault volume | |
| | unsealed at boot, mounted at the Vaultwarden data dir | |
| +----------------------------------------------------------+ |
| ^ |
| | volume key derived from a quorum |
| +----------------------------------------------------------+ |
| | keep-frost-net: FROST / OPRF networking over Nostr | |
| +----------------------------------------------------------+ |
+--------|----------------------------------------------------------+
| encrypted, authenticated Nostr messages
v
bundled relay (wisp) <-----> phone holder replica holder
(untrusted; sees only (key share) (key share)
ciphertext)
keep-web
keep-web is the Keep headless daemon, built from the keep-web crate in
privkeyio/keep. It provides the encrypted key vault,
a FROST threshold co-signer, a NIP-46 remote-signing bunker for Nostr, and an
authenticated admin API. It listens on loopback by default (127.0.0.1:8080); the admin
API and the bunker are not exposed off-box. Reach them over the encrypted transport.
Vaultwarden
Vaultwarden is run unmodified as a service. It is bound to localhost only
(ROCKET_ADDRESS = 127.0.0.1) and self-registration is disabled by default
(SIGNUPS_ALLOWED = false), so the node is not an open signup endpoint and never serves
plaintext HTTP on the LAN. No firewall port is opened for it; access is over the encrypted
transport, which terminates to localhost.
Vaultwarden is zero-knowledge end to end: the server only ever holds the encrypted user key plus an auth hash, so it cannot decrypt a user’s vault. Keep Node therefore does not modify Vaultwarden or its clients. The threshold guarantee is delivered one layer down, at the data-at-rest and service-availability layer, by putting Vaultwarden’s entire state directory on the gated volume.
frost-gate
The frost-gate is the encrypted LUKS volume that holds Vaultwarden’s data directory (and
Keep state). It is a oneshot systemd service, keep-node-frost-gate, that runs before
the volume is mounted. On first boot it provisions the volume (LUKS format, key enrollment,
and mkfs); on every later boot it unlocks the volume and mounts it at the data directory.
The gate is deliberately fail-closed. It refuses to reformat a device that already holds
data it did not create, it keeps no local recovery keyslot, and if the volume cannot be
unlocked it stays down rather than serving without the data. By default the gate seals the
volume key to the box’s TPM (PCR 7 today; a real measured-boot policy needs the opt-in
measured-boot.nix). Deriving that key from a device quorum instead, so that a powered-on box
alone cannot release it, is the subject of the Threshold Unlock chapter:
the gate’s opt-in mode = "oprf" wires that quorum unlock, and it is validated end to end (relay
- holder + box) with the real
keepbinary in theoprf-gateVM test. The relay-response parser runs in a confined, non-root scope, so a bug handling a hostile relay cannot escalate.
keep-frost-net
keep-frost-net is the networking layer that carries FROST signing and the threshold-OPRF
unlock between devices. It transports messages over Nostr, with per-peer authentication,
attestation, and replay protection. The unlock uses a dedicated OPRF-evaluation session,
distinct from the signing sessions, described in the next chapter.
The relay (wisp)
Nostr coordination (FROST, the bunker, the unlock session, peer discovery) needs a relay. Keep Node
bundles wisp, privkey’s own lightweight Nostr relay, so a deployment does not depend on third-party
public relays. It is the relay the threshold-OPRF quorum is meant to coordinate over: the oprf-unlock
tests run the real keep quorum against wisp (dogfooding it in place of a stand-in relay), including
with NIP-42 authentication required , keep authenticates automatically and an unauthenticated
client is refused. Run on-box via the opt-in keepNode.wisp module, it binds to the mesh interface
only, so it is reachable over the encrypted mesh and refused on the LAN/underlay.
The OPRF and duress relay endpoint is operator-configured (keepNode.frostGate.relay) rather than
defaulted, so an operator should point it at this on-box mesh wisp to keep the quorum’s coordination
inside the WireGuard mesh. When it is, a network observer sees only WireGuard and only the relay host
sees Nostr metadata; when it is aimed at an off-mesh relay instead, that relay (still untrusted for
content) also sees the coordination traffic’s shape. Binding this default to the mesh is a planned
hardening step.
The relay is untrusted infrastructure: it stores and forwards ciphertext only, never plaintext, and holds at most one key share, below the quorum threshold, so a compromised relay still cannot decrypt or sign.
Roles: box, phone, replica
Key custody is split across three roles in a 2-of-3 quorum:
- Box: the appliance itself. It holds one key share and is also the initiator of an unlock.
- Phone: a second holder. Because the box’s share alone is below threshold, the phone has to take part in every unlock; the approval prompt on the phone is a cryptographic necessity, not just a policy check.
- Replica: an optional third holder (another node, an air-gapped signer, or a managed ciphertext-only replica). It provides availability and recovery without ever holding a full key.
No single device, including the box, ever holds enough to decrypt the vault or to sign.
Running two or more nodes in active/standby, so a single node failing does not take the vault
down, is implemented by vault-replication.nix (see Multi-node sync): the
standby shares the active’s JWT signing key, tails its DB (Litestream) and attachment/Send files
over the nvpn encrypted mesh (mesh.nix), and on a crash a promote step restores and serves them.
The mesh-replication test drives this end to end , replicate, propagate a deletion, crash the
active, promote the standby , over a real mesh with no relay. Replicas only ever exchange the
application state above the LUKS layer, re-encrypted under each node’s own volume, never plaintext or
a quorum-threshold set of shares, and the mesh authenticates peers (npub roster) and encrypts the
hop (WireGuard). Relay-based peer discovery , nodes learning each other’s endpoints over a wisp relay
instead of static config , is implemented as the opt-in keepNode.mesh.discovery mode and proven in
the mesh-discovery test (two nodes form the mesh with no static endpoints). Full symmetric-NAT
traversal (hole-punching over the relay’s ephemeral channel) is the remaining internet-deployment
piece beyond the VM.
Boot flow
The ordering is what makes the guarantee hold: nothing that needs the vault starts before the vault is unsealed.
- The appliance boots into NixOS.
keep-node-frost-gate.serviceruns. On first boot it provisions the LUKS volume; on later boots it unlocks it.- The gate mounts the decrypted volume at Vaultwarden’s data directory.
vaultwarden.serviceis orderedAfter=andrequires=the gate, so it starts only once the volume is unsealed and mounted. If the gate fails, Vaultwarden does not start.
Because Vaultwarden hard-requires the mount, a node that cannot reach its quorum (or, in the current image, whose TPM refuses to release the key) simply does not bring the password manager up. That is the intended behavior.
Threshold Unlock
This is the heart of Keep Node: how the encrypted vault’s volume key is derived from a
quorum of devices using a threshold Oblivious PRF, without any single party ever
reconstructing the key. The construction and its protocol are implemented in the Keep
crates (keep-core’s oprf module and keep-frost-net’s OPRF session); this chapter
describes what that code does and is careful to mirror its own hedged language about what
it protects. Wiring this quorum unlock into the appliance boot gate, in place of the
current TPM-sealed volume key, is in progress; this chapter describes the design those
crates implement.
The problem
Full-disk encryption protects data at rest, but the volume key has to come from somewhere. A key sealed only to the box’s TPM is local protection: it binds the key to a measured boot state on that one machine. It does not make the key a quorum. A powered-on box that holds its own TPM-sealed key can unlock its disk by itself, so a TPM-only seal does not, on its own, satisfy the property that no single device can decrypt the vault.
The goal of the threshold unlock is to make the volume key depend on a quorum of separate holders, so that:
- A stolen, powered-off box holds only one share of the secret, below threshold, and is cryptographically unable to derive the key.
- Every unlock requires another holder (the phone) to take part, which makes the phone’s approval a property of the cryptography rather than a policy that could be flipped.
The construction
The primitive is a 2-of-3 (configurable t-of-n) threshold Oblivious PRF over secp256k1.
An Oblivious PRF lets a client compute a pseudorandom function of an input under a server’s key, where the server learns neither the input nor the output, and the client learns nothing about the key. The base construction is the 2HashDH OPRF standardized in RFC 9497: the client blinds its input to a group element, the server applies its key to that element, and the client unblinds and finalizes to a uniform output.
RFC 9497 defines no secp256k1 ciphersuite, so Keep binds the generic prime-order-group
construction to secp256k1 by implementing the voprf crate’s CipherSuite trait over
k256::Secp256k1. Hash-to-curve is the RFC 9380 suite
secp256k1_XMD:SHA-256_SSWU_RO_, provided by k256’s hash2curve feature. The suite ID
is non-standard and is pinned in the code; it feeds the RFC 9497 context string, so it can
never change once it has derived keys that protect data, because changing it changes every
derived key.
The threshold layer makes this t-of-n. The OPRF key s is Shamir-split across the holders
(box, phone, replica). For a client’s blinded element B, each holder i returns a
partial evaluation s_i * B using only its share s_i. A quorum of these partials is
combined in the exponent by Lagrange interpolation (using vsss-rs’s in-group share
combination) into B^s, without any party ever reconstructing s. The combined
element is fed back into the unchanged OPRF finalize step. The only bespoke arithmetic in
the whole path is the single multiplication s_i * B; the Shamir split, the in-exponent
Lagrange combination, the group operations, and the blind and finalize are all done by
vetted libraries.
The correctness of this is checked directly in the code: for any 2-of-3 quorum, the threshold combination produces exactly the same OPRF output as a single server holding the un-split key, and the key is never reconstructed.
Deriving the volume key
The OPRF output is already a uniform SHA-256 PRF value. It is run through HKDF (RFC 5869) to produce the 32-byte LUKS key:
K_luks = HKDF(oprf_output, info = "keep-node/luks/v1" || len(volume_id) || volume_id || epoch)
The info is built injectively, length-prefixed, so two distinct (volume_id, epoch)
pairs can never collide into the same key. The volume id separates multiple volumes and the
epoch allows the derivation to be rotated without changing the OPRF key. The result is fed
to a LUKS2 keyslot via cryptsetup ... --key-file - --keyfile-size 32.
Enrollment (provisioning)
Provisioning is the one moment the box holds the OPRF secret, and it is brief.
- The box generates a random OPRF key
sand uses the fixed unlock inputkeep-node-vault-v1. - The box derives
K_luksonce fromsand that input, and uses it to LUKS-format the data volume. - The box Shamir-splits
sinto a 2-of-3 set of shares and distributes one share to each holder (box, phone, replica) over an authenticated, encrypted pairing channel. - The box zeroizes
sand every transient copy of a share it handled. From this point no party holdss, and no party holds two or more shares.
Each holder’s OPRF key share is dedicated to the unlock and is separate from any FROST signing share, so the same secret is never reused across both Schnorr signing and OPRF evaluation. The box’s own share is sealed to its TPM, bound to a measured boot state, so that an attacker cannot read it off a stolen disk.
The split itself is handled carefully: the returned shares are live key material, and the caller is responsible for zeroizing each one once it is distributed. The split rejects invalid parameters (a threshold above the share count, or a threshold below two).
Unlock (per boot)
On each boot the box drives one unlock attempt. The box is both the OPRF client and one of the three holders.
- The box blinds the fixed input, producing the blinding state (kept locally until finalize) and a 33-byte blinded element to send to the holders.
- The box computes its own partial evaluation locally from its TPM-sealed share.
- The box sends an OPRF-eval request, carrying the blinded element, to the eligible
holders over
keep-frost-net. A session id is derived from the blinded element and the participant set. - A holder that passes every gate (below) evaluates the blinded element with its share and returns a 65-byte partial evaluation (a 32-byte Shamir identifier plus the 33-byte compressed point). The holder never sees the input or the output, only the blinded element.
- Once the box has a quorum of partials (its own plus one holder’s), it combines them,
finalizes the OPRF output, and derives
K_luks. The derived key never crosses the wire. - The box opens the LUKS volume with the derived key and mounts it. Intermediates are zeroized.
The box collects partials keyed by share index, rejecting a non-participant, a duplicate index, or a wrong-length partial, and finalizes only when the quorum threshold is reached. The session times out (30 seconds) if a quorum never arrives. A replayed partial (a duplicate Shamir identifier) is rejected by the combination, so a single holder cannot forge a quorum by answering twice.
The eval oracle and why it defaults closed
The unlock input is a single fixed, low-entropy label. That is a deliberate design choice, and it is also the reason the holder’s evaluation oracle is the security spine of the whole scheme. If a holder would auto-answer evaluation requests, an attacker who can reach the oracle could obtain the partial it needs and derive the key, because there is no secret input to guess. So a single unguarded, auto-answered evaluation can be enough to reveal the key. The protection is to bound and gate who may obtain an evaluation.
The holder’s eval handler in keep-frost-net applies a sequence of gates before it will
evaluate anything, and only invokes the oracle if all of them pass:
- Group and participant checks. The request must be for this node’s group and name this node as a participant; a node with no OPRF share silently ignores the request.
- Replay window. Requests outside the configured replay window are rejected.
- Receive policy. The sender must be allowed to send eval requests.
- Identity binding. The sender’s public key must match the share index it claims.
- Attestation. The requester’s attestation must be
Verified, not merely “attested”. A node guarding a real vault must configure expected boot measurements (PCRs) and will only answer a requester whose measured boot verified. Accepting an unconfigured peer would be fail-open, so it is refused. - Rate limit. Each requester is capped with a sliding window (the implemented default is eight evaluations per sixty seconds), and the tracking table is itself hard-capped and fails closed if it ever fills. Bounding the number of evaluations is precisely what keeps the fixed, low-entropy input from being brute-forced.
- Operator approval. Finally an approval hook is consulted. It is default-deny: a holder answers only if it has opted in with an explicit policy. The phone implements this as the user-facing “tap to approve” prompt. A node emits an event when an eval is requested so that prompt can be raised.
The oracle defaults closed at the last step on purpose. The honest reading is that the quorum’s resistance to guessing depends on this oracle being authenticated and bounded; an unbounded or unauthenticated oracle would reduce the unlock to an offline brute force of a known input.
Fail-closed and recovery
The scheme is designed so that the failure mode is a failed unlock, never a key leak.
A wrong partial, whether from a misbehaving holder or a forged or corrupted message, interpolates a different key. That yields a different OPRF output, hence a different derived key, which LUKS rejects at the keyslot digest. The box surfaces the failure and tears the session down rather than waiting out its timeout. It does not, and cannot, learn anything about the real key from a wrong partial. This property is checked directly in the code: a quorum that mixes in a share from a different key derives a different LUKS key.
Malformed inputs are rejected rather than trusted: off-curve points, identity elements, non-canonical or zero Shamir identifiers, and wrong-length partials all return errors at the trust boundary.
Per-partial DLEQ verifiability (a zero-knowledge proof that a holder used its committed share) is a planned follow-on, not a security gate for the core guarantee. Because a wrong partial already yields a wrong key that LUKS rejects, a misbehaving holder causes a failed unlock, never a silent corruption or a leak. DLEQ would upgrade that fail-safe into a diagnosable, denial-of-service-resistant one; it would not change what an attacker can learn.
Recovery follows from the same 2-of-3 structure and needs no local recovery secret. A blank
replacement box is just an OPRF client with no share of its own; it blinds the input,
requests partials from any two surviving holders (for example phone plus replica),
combines, derives K_luks, and opens a replicated copy of the volume. There is no seed
phrase, and no share ever leaves a holder during recovery.
The boundary, stated plainly
Threshold unlock protects the volume key at rest and gates every unlock on a second holder. It does not protect a live, actively compromised running box. A box that is compromised at unlock time necessarily learns that volume’s key, because it has to, in order to mount the disk. That is true of all full-disk encryption, and Keep Node does not claim otherwise. What the quorum adds is that an offline box, a powered-on box without its quorum, and a stolen disk all come up empty.
Security
This chapter sets out what Keep Node protects, what it does not, and the assumptions those guarantees rest on. It is deliberately conservative: a security appliance is only as honest as its threat model.
Current status
Keep Node is an early scaffold, so this chapter describes the target design alongside what ships today. Read every guarantee below in that light:
- Today (default): the vault is a TPM-sealed LUKS volume that auto-unlocks at boot,
with the key sealed to PCR 7 only (Secure Boot policy, not a real measured-boot
policy). This is full-disk encryption at rest: a powered-on box can unlock itself, there is
no second holder, and there is no phone. An opt-in
oprfgate mode wires the threshold-OPRF quorum unlock; the crates and the unlock path exist and are tested, but it is not the default, and it is only a fully verified gate once measured boot is enabled (available as the opt-inkeepNode.measuredBootmodule, off by default). - Target (in progress): the 2-of-3 OPRF quorum described below, with a phone holder
that does not exist yet and the box’s share sealed under a real measured-boot PCR
policy (the Lanzaboote work, shipped as the opt-in
keepNode.measuredBootmodule but not the default appliance). The “no single box can decrypt” property holds only once those are in place.
Sections below describe the target unless a “today” note says otherwise.
Cryptography
| Component | Implementation |
|---|---|
| Volume encryption | LUKS2 (cryptsetup), 32-byte key on a keyslot |
| Threshold unlock | 2-of-3 Oblivious PRF over secp256k1 (RFC 9497 construction) |
| Hash-to-curve | RFC 9380 secp256k1_XMD:SHA-256_SSWU_RO_ (via k256) |
| Threshold layer | Shamir split + in-exponent Lagrange combination (vsss-rs) |
| Key derivation | HKDF (RFC 5869) with injective domain separation |
| Threshold signing | FROST (BIP-340 Schnorr), from keep-core |
| Transport | Nostr, authenticated and encrypted, via keep-frost-net |
| Memory hygiene | zeroize on key material and intermediates |
The threshold unlock primitive is covered in detail in the Threshold Unlock chapter.
Threat model
What each holder holds
The unlock secret is split into a 2-of-3 quorum across three heterogeneous holders. No holder ever sees the volume key; each holds one share and contributes only a blinded partial evaluation. Any two of the three reconstruct the key in the exponent; any one cannot.
| Holder | What it holds | What protects it | What compromising it alone yields |
|---|---|---|---|
| Box (this node) | One OPRF share, TPM-sealed under a measured-boot policy; never on disk in the clear | The TPM seal + measured boot | One share, below threshold: no key, and the box cannot self-unlock |
| Phone | One OPRF share in the phone’s hardware-backed keystore; also gates approval | Mobile hardware keystore + user-present approval | One share, below threshold: no key; a wrong partial only fails the unlock |
| Replica | One OPRF share on a second node or a managed ciphertext-only replica; provides availability and recovery | The replica’s own boot/attestation posture | One share, below threshold: no key |
A wrong or malicious partial from any single holder causes a failed unlock, never a key leak: the combination is in the exponent, so an incorrect share simply does not reconstruct.
Today: none of this quorum is active by default. The box holds a TPM-sealed LUKS key
directly (PCR 7), there is no phone holder, and the replica quorum is not yet wired. The
table describes the target that the oprf gate mode and the holder devices are being built toward.
A stolen, powered-off box
An attacker who walks off with a powered-off node gets ciphertext. The vault volume is LUKS-encrypted and its key is not stored on the disk: today the key is sealed to the box’s TPM (PCR 7), which will not release it on a different machine or a changed boot state, and in the target the box holds only one share of the unlock secret, below the quorum threshold and cryptographically insufficient to derive the key. Either way the box keeps no local recovery keyslot to fall back on (unless the opt-in recovery keyslot is enabled). Stealing the box, powered off, yields nothing usable.
A powered-on box
This is where today and the target differ most. Today, with the default TPM-only seal, a
powered-on box does unlock its own vault: the TPM releases the PCR-7-bound key at boot
with no second party. That is exactly the limitation the quorum exists to remove. In the
target, a running box still cannot unlock by itself: deriving the volume key requires a
quorum, so a second holder (the phone) has to evaluate the blinded element and approve. That
approval is not a policy toggle the box can flip; it is a necessary step in the cryptography,
because the box’s single share is below threshold. The opt-in oprf gate mode is the first
step toward this, but the “a powered-on box cannot self-decrypt” property holds only once the
phone holder and a real measured-boot policy are both in place.
The honest boundary: a box that is actively compromised at the moment it unlocks necessarily learns that volume’s key, because it must, in order to mount the disk. This is true of all full-disk encryption. The quorum protects the key at rest and gates every unlock on a second holder; it does not protect a live, compromised, running system. Keep Node does not claim to.
A stolen operator laptop
Admin access is key-only SSH as keepadmin, reachable only over the mesh. With a software key, that
key is a file: an attacker who takes the laptop, or lands malware on it, inherits full node admin
(including passwordless sudo), silently and replayably, for as long as the key exists.
keepNode.security.yubikey narrows that. With a FIDO2 credential (ed25519-sk / ecdsa-sk) the
private key lives in the token’s secure element and cannot be extracted or copied, and
verify-required makes every single authentication need a PIN and a physical touch. Possession of
the laptop stops being sufficient; remote malware cannot authenticate without the operator physically
present at the token. requireHardwareKey = true enforces this at sshd (PubkeyAcceptedAlgorithms
restricted to sk- algorithms), so a software key is refused by the daemon rather than merely absent
from a file.
The boundary: this protects the authentication, not a session already established. An attacker who compromises the laptop while the operator is logged in can act inside that live session; the touch requirement bounds the damage to the moments the operator is present, and the credential cannot be stolen for later use. It is also strictly node access , it does not gate the vault, whose protection is the FROST quorum above. Losing every token costs SSH (physical console is the break-glass), never the vault.
Duress and coercion
The quorum protects against theft and remote compromise. It does not, on its own, protect against an attacker who coerces a present, cooperating user into unlocking with the real credential: that user can be forced to drive the real quorum, and Keep Node does not claim to stop it.
For that threat Keep Node ships an opt-in coercion response. A holder provisions a duress
credential, chosen distinct from the real vault password. Entering the duress credential at
serve fails closed: the vault is never unlocked and the holder’s OPRF share is never loaded,
so the box drops below the quorum threshold and no key is reconstructed, and the holder emits a
signed duress beacon to the rest of the group. Holders that have pinned that beacon key
verify it and freeze: they refuse co-signing and OPRF evaluations, so the whole group fails
closed rather than only the coerced node. The freeze is sticky across reboot, so a coerced
holder cannot be quietly restarted back into service. The in-band way to lift it is a delayed,
cancelable operator clear, so an attacker who compels a “clear” still faces a waiting period in
which a legitimate operator can abort it. The persisted freeze marker itself rests on filesystem
permissions: it lives in a root-owned, non-writable directory, and that permission boundary, not
the delay, is what stops an attacker from simply deleting the marker to resume service. This is a
non-destructive alert-and-freeze: it wipes nothing and it is not a decoy.
The beacon’s contents are metadata-private on the wire. It is a NIP-59 gift wrap, an ordinary
kind:1059 event authored by an ephemeral key, so an untrusted relay cannot read its payload and
cannot recover the beacon key, the group, or a duress label from it. What the encryption does not
hide is the shape of the traffic, and the honest limits are stated plainly below because this is
security-first and not a marketing claim:
- Traffic shape, not content, still leaks. A relay that sees Nostr metadata can observe
that
kind:1059traffic is happening, its size, and its re-broadcast cadence, even though the content stays encrypted. Shaping that traffic to be indistinguishable from cover traffic is tracked future work. Carrying the OPRF and duress coordination over the on-boxnvpnmesh (WireGuard) already hides it from any network observer, leaving only the relay host. - Availability is not guaranteed; failing closed is. An active relay that drops or delays the beacon can keep other holders from freezing. This is the same “a compromised relay degrades availability” caveat that applies to all relay traffic here. The beacon re-broadcasts, and a frozen quorum is the safe state, but delivery against a censoring relay is not promised.
- The beacon pin is a shared secret of modest strength. Every holder must hold the pin to act on it, and the beacon key derives from a possibly low-entropy duress credential, hardened with Argon2id but not unbreakable. A leaked pin lets an attacker grind the credential offline and forge a freeze: a denial of service on availability, never a path to the key. Hardening this beyond a grindable key is tracked work.
- It does not defend a user coerced into the real unlock. Duress is something the user opts into by entering the duress credential; it does nothing against a coercer who already knows the real credential and watches the user use it.
Separately, for concealing that a second vault exists at all, the underlying keep-core
carries a hidden-volume primitive (keep_core::hidden): a vault can hold a second storage area
cryptographically indistinguishable from random padding, so unlocking with a decoy credential
reveals only the decoy contents and the hidden area’s existence cannot be proven. Keep Node does
not yet expose a decoy-credential unlock flow on top of this primitive; that remains future
work and is distinct from the duress-beacon response above.
The evaluation oracle
Because the unlock input is a fixed, low-entropy label, the holder’s evaluation oracle is the part of the system that most needs protecting. An unbounded or unauthenticated oracle would reduce the unlock to an offline brute force of a known input. The oracle is therefore gated on every request: group and participant membership, a replay window, a receive policy, a binding between the requester’s identity and its share index, verified attestation, a per-requester rate limit, and a default-deny operator approval hook. The oracle answers only when all of these pass. It fails closed.
The relay and the network
The Nostr relay (and any networking between nodes) is untrusted. It stores and forwards ciphertext only, never plaintext, and it holds at most one share, below the quorum threshold. A compromised relay can drop or delay traffic, which degrades availability, but it cannot decrypt the vault, derive the volume key, or sign. Vaultwarden is bound to localhost and never serves plaintext HTTP on the LAN; remote access is over the encrypted transport, which terminates to loopback.
Because the box reconstructs its volume key by parsing responses from that untrusted relay,
that parser is a semi-trusted-input attack surface. The gate does not run it inline in the
privileged unit that drives cryptsetup and the mount: the every-boot keep oprf-unlock runs
in a tightly-confined transient scope, as a dedicated unprivileged user with an empty
capability set, no-new-privileges, a @system-service syscall filter, MemoryDenyWriteExecute,
and no access to the PID1/D-Bus control sockets. A memory-safety or logic bug handling a
malicious relay response therefore cannot escalate to root or read the box’s other secrets
(it reaches only its own FROST-share database, the TPM device, and the relay socket); the
reconstructed 32-byte key returns over a pipe to the privileged unit that opens the volume.
Inter-node replication (the multi-node HA) is held to the same rule: nodes replicate each other’s
encrypted application state only, never plaintext and never an at-or-above-threshold set of shares,
so a node or its sync path can degrade availability but cannot decrypt another node’s vault. It is
implemented and runs over the nvpn encrypted mesh, which authenticates peers by their Nostr
identity (npub roster) and encrypts every hop (WireGuard); the standby’s replica receiver is exposed
only on the mesh interface, so nothing on the LAN or the WireGuard underlay can reach it. A promoted
standby that serves the vault necessarily decrypts it and is therefore a fully trusted holder (a
warm standby), exactly as the cold-vs-warm discussion in Multi-node sync
sets out; replication itself moves only ciphertext.
Trust assumptions
- Measured boot and attestation. In the target, the box’s key material is released only
in the expected boot state, and a holder only evaluates for a requester whose attestation
is verified. Today the seal binds PCR 7 only (Secure Boot policy), which is weak: a real
measured-boot policy that also covers the kernel/initrd/UKI (PCR 11, the Lanzaboote work)
is not the default; enable it with the opt-in
keepNode.measuredBootmodule. The attestation verifier itself does enforce a full PCR set, a fresh nonce, and a pinned key; but these guarantees are only as strong as the boot stack that populates those PCRs, and a node guarding a real vault must configure its expected measurements, since an unconfigured peer is treated fail-open and is therefore refused. - Holder devices. The phone and replica are trusted to hold their shares and to gate approval. Compromise of a single holder is survivable: one share is below threshold, and a wrong partial only causes a failed unlock, never a key leak.
- The libraries. The dangerous cryptography is delegated to vetted crates (
voprf,vsss-rs,k256,hkdf,zeroize). The bespoke surface is small and is reviewed independently.
Fail-closed defaults
The system is built to fail toward refusing service rather than toward exposing data:
- The volume gate refuses to reformat a device that already holds data it did not create.
- It keeps no local recovery keyslot; recovery is from a replica or the quorum, never a secret at rest. A change in the box’s boot measurements makes its own copy of the volume unreadable until re-provisioned from a replica, by design.
- If the volume cannot be unlocked, Vaultwarden does not start. The password manager hard-requires the mount.
- The operator approval hook on the evaluation oracle is default-deny.
- The rate-limiter’s tracking table is hard-capped and refuses new identities rather than growing without bound.
Reporting vulnerabilities
If you discover a security vulnerability, please report it privately rather than opening a public issue. Use GitHub Security Advisories, or email security@privkey.io. Please include enough detail to reproduce the issue, and allow time for a fix before any public disclosure.
Deployment
Two ways to stand up a keep-node box:
- Bring-up (debug) profile , the installer ISO (
keepnode-debug). Insecure by design (known root password, password SSH, open signups) so a fresh box is reachable over the LAN before the encrypted transport exists. For evaluation only. See the README’s “Install on hardware”. - Hardened (declarative) profile ,
nixosConfigurations.keepnode, deployed from a config you author. This is the real deployment: the encrypted mesh forms at boot from your config, admin access is key-only over that mesh,debugAccessis off, and signups default-deny. The rest of this page covers it.
The model is a co-owned cluster: you (the operator) hold every node’s config and every peer’s identity, so the whole mesh , nodes and your admin laptop , is provisioned declaratively. The mesh is the perimeter; nothing admin-facing is exposed to the LAN.
1. Generate a mesh identity per participant
Every mesh participant , each node and your admin laptop , needs an nvpn identity: a config.toml
and its secret file, produced by nvpn init. The identity’s npub is how peers refer to it, and
it must be known at deploy time so the roster can be authored ahead of boot.
# For each participant, in an isolated dir:
XDG_CONFIG_HOME=$PWD/node-a nvpn init # prints: nostr_pubkey=npub1...
# -> node-a/nvpn/config.toml + node-a/nvpn/.config.toml.nostr-secret-key.secret
Record each npub. keepNode.mesh.identityDir points at an on-target directory holding exactly two
files: config.toml and secret , the latter being nvpn’s .config.toml.nostr-secret-key.secret,
renamed to secret:
mkdir node-a-identity
cp node-a/nvpn/config.toml node-a-identity/config.toml
cp node-a/nvpn/.config.toml.nostr-secret-key.secret node-a-identity/secret
Deliver that directory to the node out-of-band (e.g. agenix/sops onto the encrypted volume) at a
runtime path , never as a Nix-path literal, or the secret key lands in the world-readable
/nix/store; the module refuses a store path.
2. Author each node’s mesh roster
This how-to uses static endpoints: each node lists its own advertised endpoint and every peer’s
npub + endpoint. Set the same networkId on all of them. (An opt-in keepNode.mesh.discovery mode
instead learns peer endpoints over a wisp relay , the mesh-discovery test proves it , for nodes with
dynamic addresses; static endpoints are the simplest to start with.)
# node A's configuration.nix (node B is symmetric, with A in its peers)
keepNode.mesh = {
enable = true;
package = nvpn; # the nvpn package
networkId = "keepnode"; # shared across the cluster
identityDir = "/run/secrets/mesh-id"; # node A's identity, delivered out-of-band
selfEndpoint = "203.0.113.10:51820"; # this node's advertised ip:port
peers = [
{ npub = "npub1...b"; endpoint = "203.0.113.11:51820"; } # node B
{ npub = "npub1...laptop"; endpoint = "203.0.113.50:51820"; } # your admin laptop
];
};
At boot, keep-node-mesh-prepare installs the identity and applies the roster, and
keep-node-mesh.service forms the mesh , no manual nvpn init/nvpn set. On a FROST-gated node the
identity is placed on the encrypted volume, fail-closed. Each node gets a deterministic 10.44.x.y
mesh IP.
3. Enable admin SSH over the mesh
keepNode.adminAccess gives a hardened, key-only keepadmin account reachable only over the mesh
interface , the LAN and the WireGuard underlay never reach sshd. To administer the cluster, your
laptop joins the mesh (step 1-2 above, as a peer) and you SSH to a node’s 10.44.x.y address.
keepNode.adminAccess = {
enable = true;
authorizedKeys = [ "ssh-ed25519 AAAA... you@laptop" ]; # your SSH public key(s)
};
- Key-only (
PasswordAuthenticationoff);rootis not a network username (PermitRootLogin no);keepadminis inwheelwith passwordless sudo (the SSH key is the authentication). - Public keys are public , list them inline; no secret manager needed.
- Anti-lockout: with keys empty the build is refused (password auth is off, so zero keys would
permanently lock out remote access).
adminAccessalso requires the firewall enabled and refuses to coexist withdebugAccess.
Then, from your (mesh-joined) laptop:
ssh keepadmin@10.44.x.y # the node's mesh IP; not reachable from the LAN
4. Harden admin access with a YubiKey (FIDO2)
An SSH key is a file on your laptop: steal the laptop and you inherit node admin, silently. A FIDO2
credential (ed25519-sk / ecdsa-sk) keeps the private key inside the token’s secure element, where it
cannot be copied, and verify-required demands a PIN and a physical touch on every login.
This is node access only. It is not FROST. Losing every YubiKey costs you SSH to the box (physical console remains the permanent break-glass); it never costs you the vault. Vault recovery is the FROST threshold quorum, unchanged by anything on this page.
Generate the credential (on your laptop)
Do this twice: a primary token and a backup you store off-site. One hardware key is a single point of failure for node access.
ssh-keygen -t ed25519-sk -O resident -O verify-required -C yubikey-primary
-O residentstores the credential on the token, so you can recover it onto a new laptop withssh-keygen -K(run in~/.ssh). Non-resident keys work too, but then the key handle file is something you must back up yourself.-O verify-requiredis what forces the PIN in addition to the touch.ecdsa-skinstead ofed25519-skif your token’s firmware predates FIDO2 ed25519 support.
The .pub file it writes is what you enroll; the private half never leaves the YubiKey.
Enroll it
keepNode.security.yubikey = {
enable = true;
authorizedKeys = [
"sk-ssh-ed25519@openssh.com AAAA... yubikey-primary"
"sk-ssh-ed25519@openssh.com AAAA... yubikey-backup"
];
requireHardwareKey = true; # refuse every non-hardware-backed key at sshd
};
requireHardwareKeynarrows sshd’sPubkeyAcceptedAlgorithmsto thesk-algorithms, so a software key is rejected by the daemon, not merely left out of a file. Leave itfalse(the default) while you still need software-key access; the module is fully backward compatible in that mode, and the hardware keys still carry a per-keyverify-required.keyTypes(default[ "ed25519-sk" "ecdsa-sk" ]) is the declarative allow-list of credential types. Narrow it to[ "ed25519-sk" ]to refuse ecdsa-sk outright.- Anti-lockout:
requireHardwareKey = truewith no usable hardware key fails the build; if the key lives in the runtimeauthorizedKeysFile(which the build cannot inspect), thekeep-node-hardware-key-checkunit fails loudly at boot instead, without blocking boot or sshd. - Password authentication stays disabled throughout. This module only ever removes an authentication path.
What the anti-lockout guards cannot see. Both of them , the build assertion and the boot check , read key algorithms in a file. They cannot see whether the credential on your token was created with
-O verify-required, and they cannot see whether the key body is intact. So withrequireVerification = true(the default), a token enrolled without that flag satisfies both guards and is still refused by sshd at login: both report healthy on a node you cannot reach. Actually log in over the mesh with the YubiKey , from a second terminal, keeping your current session open , before you fliprequireHardwareKeyon or remove your software key. That login is the only real proof; everything else is a file check.
Enroll after install
On a node installed from the generic ISO the closure is fixed, so add a token at runtime:
ssh keepadmin@<node>
keepnode-enroll-yubikey "sk-ssh-ed25519@openssh.com AAAA... yubikey-backup"
It validates the key, refuses anything that is not sk-, de-duplicates, appends verify-required, and
re-runs the anti-lockout checks. Enroll the backup token before flipping requireHardwareKey on.
Pass one key per invocation: a multi-line paste is refused outright, because only the first line would be validated while every line got written.
Vaultwarden: YubiKey as 2FA / passkey
Vaultwarden supports WebAuthn, but it binds every credential to the origin in its DOMAIN setting: if
that does not match the URL you actually browse, registration appears to work and then fails on the next
login. Declare it:
keepNode.vaultwarden.domain = "http://localhost:8222"; # the SSH-tunnelled default
http://localhost is a secure context, so WebAuthn works over the tunnel without TLS. Do not put a
mesh IP or LAN name behind plain http://: browsers treat that as a non-secure context and refuse
WebAuthn entirely (and Vaultwarden drops secure cookies), so registration cannot even start. Off
localhost, use HTTPS. If you enable
keepNode.ingress, it sets DOMAIN to its public HTTPS hostname and takes precedence , register your
token against whichever origin you actually use, and re-register if you change it.
Then in the web vault: Settings -> Security -> Two-step login -> FIDO2 WebAuthn, add both tokens, and save the recovery code somewhere offline. Vaultwarden’s own passkey/2FA state lives in the vault DB on the FROST-gated volume, so it is covered by the same threshold guarantee as everything else.
Installer bring-up (generic ISO)
Mesh-only admin assumes your laptop is already a rostered mesh peer. For a co-owned cluster you author
that in from the start (step 2). For a box installed from the generic ISO, where the operator key
isn’t known at build time, install-keepnode --ssh-key <pubkey|file> enrolls your key at install time
into a runtime file (keepNode.adminAccess.authorizedKeysFile) that the fixed closure reads. The
installed image is the hardened profile (no known password, key-only SSH, debugAccess off), with
keepNode.adminAccess.lanBringup = true so the key-only SSH is reachable on the LAN for first contact:
install-keepnode /dev/sda --ssh-key ~/.ssh/id_ed25519.pub
# reboot, then: ssh keepadmin@<node-ip>
Once the node has joined the mesh, redeploy with lanBringup = false for the mesh-only posture above.
Physical console access is the permanent break-glass.
See Multi-node sync (design) for what replicates over the mesh once it’s up.
Hardware bring-up
A practical runbook for standing keep-node up on a real machine, tier by tier. Written against an HP EliteDesk 800 G3 DM (Intel i5-7500, TPM 2.0, HP business UEFI), but the steps generalize to any x86_64 UEFI mini-PC with a TPM 2.0. The build is x86_64 only , an ARM SBC will not work.
Each tier is independent and additive; you can stop at any of them. Do them in order , later tiers assume the earlier ones work. The phases below group the work and do not map one-to-one to tiers: Phase 0 is BIOS prep (no tier), Tier 0 spans Phases 1-2, and Tiers 1-4 are Phases 3-5.
| Tier | Proves | Needs |
|---|---|---|
| 0. Appliance + mesh + SSH | Boots, installs, reachable over key-only SSH; mesh forms | The box (no TPM needed) |
1. TPM seal (frostGate tpm) | Vault volume auto-unlocks from this box’s TPM | TPM 2.0 on; a vault volume device |
| 2. Measured boot | Seal binds real kernel/initrd (PCR 11), not just Secure Boot policy | Secure Boot custom keys (sbctl) |
| 3. OPRF quorum | 2-of-3 threshold unlock (box + holders) | A second holder + relay |
| 4. Multi-node HA | Active/standby replication + failover | A second box |
Phase 0 , BIOS / firmware (per box)
Enter setup (F10 on the EliteDesk at power-on) and set:
- TPM: Security → TPM Device = Available, TPM State = Enabled. (Tier 1+.)
- Boot mode: UEFI (not Legacy/CSM). Required.
- Boot order: put USB ahead of the internal disk for the install, or use the one-time boot menu (F9).
- Secure Boot: leave off for now. You enable it in Tier 2 after enrolling your own keys , turning it on first with only Microsoft’s keys blocks the unsigned installer.
- Note the internal disk name later with
lsblk, on this box it is typically/dev/sda(SATA SSD) or/dev/nvme0n1(M.2). For Tier 1 you want a second disk or partition for the vault volume; the EliteDesk has a free M.2 slot , a small NVMe there is the clean option.
Phase 1 (Tier 0) , install the hardened appliance
On your workstation, build and flash the installer, then install on the box:
nix build .#installer-iso # ISO at result/iso/*.iso
sudo dd if=result/iso/*.iso of=/dev/sdX bs=4M oflag=sync status=progress # your USB stick
Boot the box from USB, then (the image has no password , your key is how you get in):
install-keepnode /dev/sda --ssh-key "ssh-ed25519 AAAA... you@laptop" # your real disk + pubkey
It wipes the disk, installs the hardened profile offline, and enrolls your key. Reboot, remove the USB, and from your laptop on the same LAN:
ssh keepadmin@<node-ip> # <node-ip> from your DHCP leases or the box's console
That’s a hardened node on real metal: key-only SSH, no known password, signups default-deny. To reach
the Vaultwarden web vault before the mesh exists, tunnel it: ssh -L 8222:localhost:8222 keepadmin@<node-ip>, then open http://localhost:8222.
Phase 2 (Tier 0, cont.) , mesh onboarding (declarative)
From here you leave the stock image and deploy your own config (a flake that imports keep-node’s
modules and sets your roster + keys). See Deployment for the full keepNode.mesh +
keepNode.adminAccess how-to; the short version:
- Generate an nvpn identity per participant (each box and your laptop) and record each npub.
- Author each box’s
keepNode.mesh(networkId, selfEndpoint = its LAN ip:port, peers = the others’ npub+endpoint, identityDir) andkeepNode.adminAccess.authorizedKeys.identityDirholds that box’s mesh secret key , deliver it out-of-band to a runtime path (e.g./run/secrets/...), never a Nix-store path, or the key lands in the world-readable store (the module asserts against this). - Deploy your config to each box , either
nixos-rebuild switch --flake .#<host> --target-host keepadmin@<node-ip> --use-remote-sudo, or a tool like deploy-rs/colmena.
The mesh forms at boot; your laptop (a mesh peer) then reaches each node at its 10.44.x.y mesh IP.
Once joined, set keepNode.adminAccess.lanBringup = false and redeploy for the mesh-only posture.
Phase 3 (Tier 1) , TPM-sealed vault volume
Add a dedicated vault volume (the second disk/partition from Phase 0) and turn on the FROST gate in tpm mode. In your config:
keepNode.frostGate = {
enable = true;
mode = "tpm"; # LUKS key sealed to this box's TPM (PCR 7)
volumeDevice = "/dev/disk/by-id/nvme-..."; # STABLE path, never /dev/sdb (see below)
recoveryKeyFile = "/root/keep-vault-recovery.key"; # opt-in escape hatch, see note
};
- Use a stable
/dev/disk/by-id/...path, not/dev/sda//dev/sdb, first-boot provisioning formats this device, and a kernel name can re-point at the wrong disk across reboots. Find it withls -l /dev/disk/by-id/. - First boot provisions + formats the volume, seals the LUKS key to the TPM, and mounts it at
/var/lib/vaultwarden. Subsequent boots auto-unlock unattended. recoveryKeyFileenrolls an extra recovery keyslot and writes the key to that path , which sits on the unencrypted root disk (only/var/lib/vaultwardenis on the sealed volume), so move it offline and delete the on-disk copy as soon as first boot writes it. It is the escape hatch if PCR 7 changes (a firmware/Secure-Boot/board change, or the Tier-2 switch to measured boot) before you have a replica to recover from. Left in place it defeats “steal the box, get nothing,” so keep it only while you are single-node and drop it once you have a second node (Tier 4).
Phase 4 (Tier 2) , measured boot (bind the seal to the real boot image)
PCR 7 alone only covers Secure Boot policy. Measured boot (Lanzaboote UKI → PCR 11) binds the seal to the actual kernel/initrd/cmdline, so a tampered boot image fails closed. This is a bigger change (it replaces the boot stack) and needs your own Secure Boot keys.
The seal’s PCR set is enrolled once, at first provision (Tier 1). Changing sealPcrs and
redeploying does not re-seal an existing volume , the gate just keeps unlocking the old,
PCR-7-bound token (the only --tpm2-pcrs enrollment is the first-boot provision path). So plan the
order deliberately:
-
Fresh box (recommended): do this phase before Tier 1 provisions the vault. Set up the Secure Boot keys and Lanzaboote (steps below), reboot into the UKI with Secure Boot on, and only then attach the vault volume with
sealPcrs = [ 7 11 ]already set , first provision then seals to PCR 7+11 from the start. -
Already-sealed box (Tier 1 done): enrolling your Secure Boot keys and turning Secure Boot on change PCR 7 itself, so the current PCR-7 seal fails closed on the next boot , this is expected, not a fault. Boot the new stack, unlock the volume with the Tier-1
recoveryKeyFile, then re-seal the token to the new PCRs by hand (the gate will not re-enroll for you):# after unlocking with the recovery key, on the box: systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7+11 /dev/disk/by-id/nvme-...
Steps (both paths share the key setup):
-
On the box, create the Secure Boot PKI:
sbctl create-keys(writes/var/lib/sbctl). -
Enroll your keys into firmware:
sbctl enroll-keys(with the box in Secure Boot Setup Mode , set in BIOS: Security → Secure Boot → clear/erase keys, which drops to Setup Mode). Optionally keep Microsoft’s keys withsbctl enroll-keys -mif any option ROM needs them. -
In your config, import Lanzaboote + the measured-boot module and set the seal PCRs:
imports = [ inputs.lanzaboote.nixosModules.lanzaboote ./nixos/measured-boot.nix ]; keepNode.measuredBoot.enable = true; # pkiBundle defaults to /var/lib/sbctl keepNode.frostGate.sealPcrs = [ 7 11 ]; # first provision seals here; re-enroll by hand if already sealed -
Deploy, then enable Secure Boot in BIOS. Verify with
sbctl verifyandbootctl status.
Never add 11 to sealPcrs before the box actually boots the Lanzaboote UKI: until then PCR 11 is
unpopulated, so a seal would bind a zero/garbage value and the next boot fails closed. The module’s own
assertion refuses a sealPcrs containing 11 unless measuredBoot.enable is set, but enabling the
module and booting the UKI are two different moments , the reboot in step 4 is what populates PCR 11.
On an already-sealed box the Tier-1 recovery key is your way back in, both for the expected PCR-7 lockout
above and for any misstep here.
Phase 5 (Tiers 3-4) , OPRF quorum (incl. geo-distributed holders) + HA
OPRF (mode = "oprf") reconstructs the LUKS key from a 2-of-3 threshold at boot (this box + two
holders). It is only a meaningful threshold gate once measured boot (Tier 2) is on AND the holders
authenticate + gate requests , until then it is no stronger than tpm (see the mode option’s
security note). The OPRF crypto path is proven in the oprf-unlock / oprf-unlock-2of3 tests; this
phase stands the same quorum up on metal, with at least one holder in a different physical location
(the real target: a holder that a local theft of the box cannot also seize).
Roles below: the vault box is this appliance (the FROST-gate dealer, runs keepNode.frostGate in
oprf mode); a holder is any device , your phone, a laptop, or a second box at another site , that
runs keep frost network serve and answers evaluations. Start with the smallest quorum that proves the
geo split (the box + one remote holder), then add the third share.
5a , Generate the group and hand out shares
On the vault box, generate the threshold group and export one share per holder:
keep frost generate -t 2 -s 3 --name g # prints the group npub; generates all 3 shares
keep frost export --share 2 --group <group-npub> # prints a kshare… bech32 for holder A (out-of-band)
keep frost export --share 3 --group <group-npub> # prints a kshare… bech32 for holder B (the remote box)
Deliver each kshare… to its holder over a channel you trust (it is share-level secret, below
threshold on its own but still sensitive), and import it there: keep frost import. The remote box is
just a holder here , it does not need a TPM or the frost-gate; it needs keep and network reach to
the coordination relay (next step).
Then delete the exported holder shares from the box so a stolen box does not also carry them (the OPRF threshold protects the LUKS key regardless, but this keeps the box below threshold on its own for the FROST shares too, defense in depth):
keep frost delete-share --share 2 --group <group-npub>
keep frost delete-share --share 3 --group <group-npub> # the box retains only its own share 1
5b , Choose the coordination relay (the one address every party must reach)
The quorum coordinates over one Nostr relay that the box and every holder can reach. Across geos this is the crux, so choose deliberately:
- A relay both sites can reach (works today). Point
keepNode.frostGate.relay(and each holder’s--relay) at awss://relay reachable from both locations. The on-boxkeepNode.wispis not it out of the box , it binds plaintext7777scoped to the mesh interface only, so reaching it off-mesh means fronting it with a TLS-terminating reverse proxy at a routablewss://address (a real proxy + firewall change, not a config toggle); otherwise use anotherwss://relay you control. The relay only ever sees ciphertext and traffic shape, never plaintext or a share (it is untrusted by design, see Security); the residual it can observe is timing/size/kind metadata. This is the recommended path for the first geo-distributed run. - Over the on-box mesh
wisp(most private, pending). Riding the coordination inside thenvpnWireGuard mesh would hide even that traffic shape from any network observer, leaving only the relay host. It is not wired yet:keeprejects a raw mesh-IP relay URL (its SSRF guard refuses internal addresses) and the mesh has no name resolver yet, so there is no address to point--relayat. Wiring this (a mesh-relay name via nvpn’s.fipsresolver, or anallow-internalbuild scoped to the mesh) is the open transport-hardening step , settle it during this bring-up, not by guessing. Until then, use the first option and accept the documented traffic-shape residual.
Whatever you choose, use wss:// off the mesh: plaintext ws:// is test-only (the
allowInsecureWs / KEEP_ALLOW_WS opt-ins) and exposes the coordination to an on-path attacker.
5c , Bootstrap attestation (holders pin the box’s TPM quote)
Have the box announce its TPM quote and each holder TOFU-pin it, producing a policy.toml the holder
serves under. This is what makes the oracle refuse an unattested requester (the security property that
turns OPRF from “no stronger than tpm” into a real threshold). On each holder:
keep frost network attestation-provision --group <group-npub> --relay <relay> --out policy.toml --wait 60
5d , Provision the quorum (needs every holder online)
With both holders serving (keep frost network serve --group <group-npub> --relay <relay> --oprf-share-file <path> --oprf-dealer 1 --oprf-auto-approve --attestation-config policy.toml), turn on
keepNode.frostGate in oprf mode on the box and let its provision unit seal a share to every party.
Provisioning requires all three parties reachable at once (it distributes a sealed share to each);
after it, each holder reloads its newly sealed OPRF share and only the box + any one holder are
needed to unlock.
5e , Validate the threshold across a reboot and across the geo split
- Reboot with the remote holder online , the box + the remote holder should reconstruct the key and mount the vault unattended. This is the headline: a holder in another location gates the unlock.
- Reboot with every holder offline , the gate must fail closed (no mapper, Vaultwarden down). This proves the box alone is below threshold, i.e. stealing the box gets nothing.
5f , Validate the duress path across geos (optional but recommended)
Provision a duress credential and pin the remote holder to a beacon, then rehearse the coercion response end to end over the real network: entering the duress credential on one holder must freeze the pinned remote holder (co-signing + OPRF refused), survive a restart, and lift only via the delayed, cancelable operator clear. The mechanism and its honest ceiling are in Security; this is where you confirm the beacon actually reaches a holder in another location over the live relay.
Multi-node HA (Tier 4) wires keepNode.vaultReplication (role active/standby, shared JWT key,
Litestream WAL streaming, file replication) over the mesh between two boxes. See
Multi-node sync.
Gotchas
- x86_64 only , no ARM SBC.
- TPM must be enabled in BIOS before Tier 1, or provisioning fails.
- Never point
volumeDeviceat a kernel name (/dev/sdb) , use/dev/disk/by-id/.... - Enroll Secure Boot keys before Secure Boot enforcement , enabling it with only Microsoft’s keys blocks the unsigned installer/UKI.
- Keep the Tier 1 recovery key until you have a second node; a PCR change otherwise locks you out.
Multi-geo specific (Phase 5 with a remote holder)
- Sync every clock (NTP) before you coordinate. Coordination events carry a timestamp and are
rejected outside a 300s replay window, and anything more than 30s in the future is refused
outright; the duress beacon’s freshness rides the same window. Two boxes in different locations with
drifted clocks will see each other’s announces/evaluations (and a duress beacon) as stale or
future-dated, and the quorum silently never converges. Enable NTP on every node and confirm
timedatectlagrees across sites before Phase 5. - The mesh underlay needs a routable endpoint per node. The
nvpnmesh is UDP on the underlay, and nvpn refuses to advertise an RFC1918 address (STUN/hole-punching is not wired). A box behind home NAT (the remote holder’s likely situation) must advertise a routableselfEndpoint, forward the mesh listen port (default 51820/udp) on that site’s router to the box, and setselfEndpointto the publicip:port.discovery.enablemode brokers addresses over a relay but still needs the underlay itself reachable. If the mesh never reaches “peers connected”, this is almost always it. - Both sites must reach the same relay. The quorum shares one coordination relay (Phase 5b); a
remote holder that cannot reach it simply never answers, and the box fails closed as if the holder
were offline. Verify reachability (
wss://handshake) from each site before provisioning. - The remote holder is a full trust-bearing party. It holds a share and gates an unlock, so treat its host with the same care as the box; a compromised holder is survivable (one share is below threshold) but a compromised majority across sites is not.
Multi-node sync (design)
Status: in progress. Keep Node runs a single node today; the M1 active/standby HA build is underway, one increment at a time, each covered by the
ha-failovernixosTest. Landed so far: the shared JWT signing key (keepNode.vaultReplication.rsaKeyFile, installed on every node before Vaultwarden starts so a session token minted on the active is accepted by a promoted standby, and, when the FROST gate is on, written only onto the mounted encrypted volume) and Litestream WAL streaming ofdb.sqlite3(keepNode.vaultReplication.litestream.enable, the active continuously ships the vault DB’s write-ahead log to a replica a peer can restore); and attachment/Send file replication (a periodic sync mirrors theattachments/+sends/files Litestream does not carry into the same replica dir , bounded eventual consistency, since two async replicators cannot give atomic file-before-row); and crash + promote failover (keep-node-vault-promote, an operator-triggered unit that restores the vault DB + files from the delivered replica and starts Vaultwarden, so a standby takes over the failed active’s data with sessions intact because the JWT key is shared). Theha-failovertest crashes the active and asserts the promoted node serves the replicated row + attachment. Promotion does NOT fence the failed active, and because the JWT signing key is shared cluster-wide, an old active that comes back can mint tokens the promoted node accepts and resume writing (split-brain), so the operator runbook must ensure the old active stays down before promoting. The cross-node transport is now real, end to end. Thenvpnencrypted mesh (nostr-vpn, boringtun userspace WireGuard, Nostr-authenticated peers) is packaged and run headless bykeepNode.mesh, validated forming + carrying traffic over its10.44.x.ytunnel with no relay (themeshtest). WithkeepNode.vaultReplication.roleandmeshReplication, the active pushes its whole replica dir , DB replica plus the mirroredattachments/+sends/files , to a standby receiver reachable ONLY on the mesh interface; the standby restores it. And the full failover runs over that mesh: themesh-replicationtest replicates a DB row, an attachment, and a Send across the tunnel, propagates a deletion, then crashes the active and asserts the promoted standby serves the mesh-delivered data with the shared JWT key intact , the M1 Done criterion over a genuine transport, so no stand-in copy remains. The standby also carries a replication-lag health signal (meshReplication.maxLagSeconds): the active heartbeats the replica on every push, and a periodickeep-node-vault-lag-checkon the standby fails once the received heartbeat is older than the threshold, so an idle-but-in-sync standby reads healthy while a stalled/partitioned one is surfaced. Keep-state-over-wispreplication now lands too, alongside the Vaultwarden HA above: keep-web (the Keep daemon, a subsystem separate from Vaultwarden) replicates its OWN encrypted vault records , keys, descriptors, relay configs , to a standby over the on-boxwisprelay under a shared cluster identity, so a promoted node serves the same Keep secrets. See Keep-state replication below. (The 2-of-3 quorum is covered by theoprf-unlock-2of3test.) Relay-based peer discovery overwispis implemented as the opt-inkeepNode.mesh.discoverymode and tested (mesh-discovery); full symmetric-NAT traversal for internet deployment is the remaining piece beyond the VM. This chapter inventories Vaultwarden’s state and the constraints that design has to respect.
Vaultwarden (1.36.x here) keeps its state under one data directory, the FROST-gated LUKS
volume mounted at /var/lib/vaultwarden. Keep Node configures the SQLite backend, which is
single-writer; that is what makes active/standby (one node serves and writes, others tail and
can take over) the natural HA shape rather than active/active.
What is in the data directory
| Item | Kind | Server can read it? | Replicate? |
|---|---|---|---|
db.sqlite3 | SQLite database (see tables below) | Mixed, see below | Yes (authoritative state) |
attachments/<cipher>/<id> | Per-attachment files | No (E2E ciphertext) | Yes, and consistently with the DB row that references them |
sends/<id> | Bitwarden Send files | No (E2E ciphertext) | Yes, same consistency caveat |
rsa_key.pem / .pub.pem | RSA keypair that signs JWT access tokens | Yes (it is the signing secret) | Yes, must be identical on every node |
config.json | Admin-panel runtime config | Yes | Keep Node sets config declaratively via Nix, so this is normally absent/empty; sync only if the admin panel is used |
icon_cache/ | Cached favicons | n/a | No, regenerable |
tmp/, *.tmp | Scratch | n/a | No, ephemeral |
Database tables: encrypted blobs vs metadata vs server-side secrets
Three categories matter for replication and for how much a replica is trusted:
-
End-to-end ciphertext the server cannot read. Cipher contents, folder names, Send payloads, and attachment keys/filenames are client-encrypted; the server stores opaque blobs. Tables:
ciphers,folders,sends, and the encrypted columns ofattachments(akey,file_name). Replicating these leaks nothing. -
Server-readable metadata. Emails, names, IDs, timestamps/revision dates, KDF parameters, org/collection membership, policies, device identifiers and types, attachment
file_size. Tables:users(non-secret columns),organizations,collections,devices,org_policies,events, etc. Useful to an attacker, but not the vault contents. -
Server-side secrets, a replica that can read these is as trusted as the primary. This is the key finding for the “ciphertext-only replica” claim in the threat model:
users.password_hash+salt, the server-side hash of the client master-password hash.users.private_key, the user’s encrypted private key (E2E ciphertext, but still sensitive).users.totp_secret/totp_recover, TOTP 2FA secrets stored server-side in the clear.users.security_stamp, rotating it invalidates all of a user’s sessions.devices.refresh_token/push_token, live session refresh tokens and mobile push tokens.- the
rsa_keyfiles, the JWT signing key.
A replica that can serve Vaultwarden necessarily decrypts the LUKS volume and can read all of the above. A genuinely “ciphertext-only” replica is therefore a cold standby: it stores the replicated bytes but cannot read or serve them until it is keyed by the quorum. A warm standby that can fail over instantly is, by construction, a fully trusted holder. M1 must pick a point on that spectrum deliberately, per node.
Replication gotchas (active/standby)
- SQLite needs WAL streaming. Use Litestream or LiteFS to stream the WAL from the active node;
both require
journal_mode=WAL. The active node is the sole writer; standbys are read-only until promoted. Failover = promote a standby that has the replicated DB. rsa_keymust be identical on every node. A JWT signed by node A is rejected by node B if their signing keys differ, so an instant failover would force every client to re-authenticate (or break). Generate the keypair once and distribute it as a synced secret (over the encrypted mesh, onto each node’s LUKS volume) rather than letting each node generate its own on first boot.- Attachment/Send files must replicate consistently with the DB. The DB row references a file on disk by id; if the row replicates before the file, that attachment is briefly broken on the standby. Replicate files before the referencing DB transaction, or accept (and bound) an eventual-consistency window.
- Session continuity depends on
devicesfreshness. Replication lag ondevices.refresh_tokenmeans a client’s refresh token may be missing on the just-promoted standby, the client re-logs in.push_tokenlag means missed mobile push until the next sync. Bound the lag accordingly. - Client sync correctness rides on revision dates +
security_stamp. Bitwarden clients decide whether to pull using the account revision date; a standby behind on replication can serve a stale revision after failover until the next write bumps it.security_stamplikewise must be current, or a stale standby could honor sessions the primary had already revoked. - The LUKS layer is below replication. Replicate the application state over the encrypted transport (mesh); each node re-encrypts it under its own FROST-gated LUKS volume. Never ship the plaintext volume or an at-or-above-threshold set of shares between nodes.
Implications for M1
- Active/standby with WAL streaming (Litestream/LiteFS) for
db.sqlite3, plus file replication forattachments/andsends/, ordered file-before-row. - A shared, securely distributed
rsa_keyso failover does not invalidate sessions. - An explicit decision on cold vs warm standby, recorded per node, because a serving replica reads
totp_secret/password_hash/refresh tokens and is therefore fully trusted, the threat model’s “ciphertext-only replica” only holds for a cold standby. - Bounded replication lag, surfaced as an availability/consistency metric, since failover correctness (sessions, revisions, 2FA) depends on it.
Keep-state replication (keep-web)
Everything above concerns Vaultwarden. Keep also runs keep-web, its own headless daemon with an
encrypted vault (keys, wallet descriptors, relay configs, and per-node FROST shares). For a promoted
standby to serve the same Keep secrets, that vault state replicates too, over the same mesh, using the
on-box wisp relay as the transport instead of file streaming.
How it works (implemented in privkeyio/keep, wired here by keepNode.keepWeb):
- Shared cluster identity. One Keep Nostr keypair for the cluster, distributed out-of-band to every
node (like the shared Vaultwarden JWT key). The active publishes under it; the standby subscribes to
that one author. Single-writer:
stateRole = "active"publishes local writes,"standby"subscribes and reconstructs. A promoted standby is redeployed asactive. - Per-record addressable events. Each replicated redb record is one NIP-78 addressable event
(kind 30078),
d-tagkeep:<table>:<record-id>, so a newer write supersedes the old and the relay keeps only the latest. The content is the record’s already-vault-encrypted bytes, NIP-44-encrypted to the shared identity, so the relay only ever holds ciphertext. - Shares are never replicated. Only
keys,descriptors, andrelay_configspropagate. Each node keeps its OWN FROST share; the consumer rejects any other table. - Shared vault data key. The double-wrapped bytes only decrypt on the standby if both nodes share
the vault’s record-encryption key. Each node seeds the SAME key at first-run via
KEEP_STORAGE_KEY(storageKeyFile), delivered onto the encrypted volume like the other cluster secrets; each node still wraps it under its own password.
Configuration (keepNode.keepWeb): stateRelay (the mesh wisp, e.g. ws://<mesh-ip>:7777),
stateIdentityFile (the shared cluster nsec), storageKeyFile (the shared data key), and stateRole.
The keep-state-replication nixosTest brings up a relay plus an active and a standby node sharing the
data key, and asserts both create their shared-key vault and connect keep-web to the relay; the wire
round-trip (active write → relay → standby reconstruct + read-back) is covered by keep’s own e2e test.
Attribution
Keep Node is assembled from existing open-source work as much as possible, adding only the glue that is genuinely new. This chapter credits the projects it builds on. Licenses are noted where they bear on how a component is used; copyleft services are run as separate processes rather than linked into the MIT codebase.
Services
- Vaultwarden (AGPL-3.0): a lightweight server that speaks the Bitwarden protocol, used as the password manager. It is run unmodified as a separate service, not linked into Keep Node, so its copyleft license is respected.
The Keep ecosystem
- Keep (MIT): the project Keep Node is part of.
keep-web(the headless daemon, FROST co-signer, and NIP-46 bunker),keep-core(the encrypted vault, FROST signing, and the threshold-OPRF unlock primitive), andkeep-frost-net(the Nostr-transported FROST and OPRF networking) are reused directly. - FROST and the FROSTR/Bifrost lineage: Keep’s threshold signing is FROST (Flexible Round-Optimized Schnorr Threshold signatures) with BIP-340 Schnorr, and its Nostr-transported threshold protocol follows the FROSTR/Bifrost lineage of FROST key management for Nostr.
Platform
- NixOS and nixpkgs (MIT): the appliance OS base, giving reproducible builds, atomic updates, and rollback.
- systemd (LGPL-2.1+): used as a service, providing
cryptsetupandcryptenrollfor the LUKS volume and TPM enrollment, and credential sealing for the box’s key share.
Rust cryptography crates
The dangerous parts of the cryptography are delegated to vetted libraries; the bespoke surface is intentionally small.
- voprf (MIT): the RFC 9497 OPRF/VOPRF reference implementation. Keep binds a secp256k1 ciphersuite to it without an upstream patch.
- vsss-rs (Apache-2.0 / MIT): Verifiable Secret Sharing Schemes, used for the Shamir key split and the in-exponent Lagrange combination of partial evaluations.
- k256 / RustCrypto (Apache-2.0 / MIT):
secp256k1 group arithmetic and the RFC 9380
secp256k1_XMD:SHA-256_SSWU_RO_hash-to-curve (thehash2curvefeature). - hkdf (Apache-2.0 / MIT): the RFC 5869 HKDF used to derive the 32-byte LUKS key from the OPRF output.
- zeroize (Apache-2.0 / MIT): clearing key material and intermediates from memory.
Standards
- RFC 9497: Oblivious Pseudorandom Functions (OPRFs) using prime-order groups.
- RFC 9380: Hashing to elliptic curves.
- RFC 5869: HMAC-based key derivation (HKDF).
- BIP-340: Schnorr signatures for secp256k1.
The threshold OPRF construction draws on the published research on threshold and oblivious PRFs (notably TOPPSS, IACR ePrint 2017/363) for making the OPRF key t-of-n without any party reconstructing it.