Skip to content

Latest commit

 

History

History
1187 lines (852 loc) · 78.1 KB

USAGE.md

File metadata and controls

1187 lines (852 loc) · 78.1 KB

Usage

Table of Contents


Installation

Requires Rust 1.89+ (MSRV). System dependencies (for the hardware-signer serial support) vary by platform: Linux needs pkg-config and libudev (build-essential/libudev-dev), macOS needs the Xcode Command Line Tools, and Windows needs nothing extra. See BUILD.md for the full list.

Install the CLI directly:

cargo install --git https://github.com/privkeyio/keep keep-cli

From a clone:

git clone https://github.com/privkeyio/keep
cd keep
cargo install --path keep-cli   # installs `keep` to ~/.cargo/bin
# or, to run without installing:
cargo build --release
./target/release/keep --help

Quick Start

# Create your encrypted vault
keep init

# Generate a new Nostr key
keep generate --name main

# List your keys
keep list

Keys are stored encrypted at ~/.keep.


CLI Reference

Run keep <command> --help for the full flag list of any command.

Global flags (valid on every command):

Flag Description
--path <dir> Use a vault at this path instead of ~/.keep (also KEEP_HOME)
--hidden Operate on the hidden volume (see Hidden Volumes)
--no-mlock Disable memory locking, accepting degraded security

Vault & keys:

Command Description
keep init Create encrypted vault
keep generate --name <n> Generate new Nostr key
keep import --name <n> Import existing nsec
keep list List all keys
keep export --name <n> Print a raw nsec (interactive TTY only, by design)
keep delete --name <n> Delete key
keep rotate-password Change the vault unlock password
keep rotate-data-key Rotate the data-encryption key (re-encrypts every secret)
keep backup [--output <file>] Write a passphrase-encrypted vault backup
keep restore <file> --target <dir> Restore a backup into a new vault

Secrets store (see Secrets Store):

Command Description
keep secret add --name <n> [--kind <k>] Store a secret (value read from a hidden prompt or piped stdin, never argv)
keep secret add --name <n> --threshold --group <npub> --share-file <f> Store a secret sealed behind a t-of-n OPRF quorum
keep secret get <name> Reveal a secret (interactive TTY only); unseals via the quorum if threshold-sealed
keep secret list List secrets (name, kind, id) — never values
keep secret rm <name> Remove a secret

Signing & coordination:

Command Description
keep serve Start the NIP-46 bunker (and optional FROST co-signer)
keep frost ... FROST threshold operations (see FROST)
keep wallet ... Wallet descriptors, proposals, PSBT spend coordination (see Wallet)
keep bitcoin ... Addresses, descriptors, PSBT signing (see Bitcoin)
keep nip46 ... NIP-46 client app grant management (see Remote Signing)
keep enclave ... AWS Nitro Enclave operations (see Enclaves)
keep agent mcp --key <n> Run the MCP signing server (see Agent SDK)
keep gateway ... Run and manage the agent gateway, Linux only (see Agent Gateway)
keep agent connect --token-file <f> Connect an MCP client to the agent gateway, Linux only (see Agent Gateway)
keep sign <file> --group <npub> Threshold-sign a file (minisign-compatible)
keep verify <file> <sig> --group <npub> Verify a minisign detached signature

Maintenance:

Command Description
keep audit ... Inspect, verify, export, or prune the audit log (see Audit Log)
keep config show | path | init Inspect or initialize the CLI config file
keep migrate status Inspect on-disk schema migration state

Backup & Restore

Your vault lives at ~/.keep (or KEEP_HOME / --path). Back it up regularly: if you lose it and have no backup, the keys are gone.

# Write a passphrase-encrypted backup file
keep backup --output keep-backup.enc

# Restore into a NEW vault (never overwrites the active ~/.keep)
keep restore keep-backup.enc --target ~/keep-restored

restore requires an explicit --target and refuses to write over an existing vault, so a restore can never clobber your live keys.

For FROST shares specifically, use keep frost export / keep frost import (per-share, passphrase-encrypted) so shares can be moved or backed up independently. See Threshold Signatures.


Secrets Store

An encrypted store for high-value, key-adjacent secrets that belong next to your keys: BIP39 passphrases, exchange API keys, hardware-wallet PINs, recovery codes. It is not a password manager — there is no clipboard, autofill, TOTP, or URL matching — just an encrypted place for the handful of secrets you already guard as carefully as your keys. Each record's name, kind, and value are encrypted under the vault data key at rest; values are never passed on the command line (read from a hidden prompt or piped stdin) and are scrubbed from memory on drop.

# Store a secret; the value is read from a hidden prompt (or piped stdin)
keep secret add --name exchange-api --kind api-token

# Pipe a value non-interactively (a single trailing newline is stripped)
printf '%s' "$MY_SECRET" | keep secret add --name seed-passphrase --kind password

# List (names/kinds/ids only, never values), reveal (interactive TTY only), remove
keep secret list
keep secret get exchange-api
keep secret rm exchange-api

Kinds are labels only (password, api-token, note, generic). Secrets are included in keep backup and re-encrypted by keep rotate-data-key like every other record, and they never sync to relays.

Threshold-sealed secrets

Seal a secret's value behind a t-of-n OPRF quorum so it can be revealed only by assembling the quorum, never from a single device — even one with the vault unlocked. The name and kind stay readable locally; only the value is quorum-gated. This reuses the same OPRF root and holders as Threshold-OPRF Vault Unlock: provision the quorum once (keep frost network oprf-provision, with holders serving), then:

# Seal a new secret under the quorum (holders must be online and serving).
# --group and --share-file are required.
keep secret add --name cold-storage-passphrase --kind password --threshold \
  --group npub1... --share-file box.share --tpm-tcti device:/dev/tpmrm0

# Reveal it — runs the t-of-n quorum to unseal the value:
keep secret get cold-storage-passphrase \
  --group npub1... --share-file box.share --tpm-tcti device:/dev/tpmrm0

If you pass the OPRF options to secret add without --threshold, the command refuses rather than silently storing a plaintext secret you meant to seal. A threshold reveal fails closed if the quorum is unreachable: the value stays sealed and no reveal is recorded.


Remote Signing (NIP-46)

Sign from any NIP-46 compatible client without exposing your private key:

keep serve --relay wss://bucket.coracle.social

This displays a bunker URL to paste into your client.

Controls: Y approve, N reject, Q quit

Pre-granting client apps (headless)

Instead of approving each connection interactively, pre-authorize a client app's pubkey so keep serve accepts it automatically. This is the headless alternative to the interactive prompt.

# Grant a client app a set of permissions
keep nip46 grant <client-npub> \
  --name "my-client" \
  --permissions get_public_key,sign_event \
  --auto-approve-kinds 1,7 \
  --duration forever

# List existing grants
keep nip46 apps

# Revoke a grant
keep nip46 revoke <client-npub>

# Globally auto-approve specific event kinds for every client (interactive serving)
keep nip46 auto-approve --kinds 1,7

Permission names: get_public_key, sign_event, nip04_encrypt, nip04_decrypt, nip44_encrypt, nip44_decrypt (or all). --duration accepts session, forever, or a number of seconds.


Bitcoin

BIP-86 Taproot addresses and PSBT signing. --network (mainnet, testnet, signet, regtest) is required on every command:

# Get receive addresses
keep bitcoin address --key main --count 5 --network mainnet

# Export the watch-only descriptors (receive and change)
keep bitcoin descriptor --key main --network mainnet

# Analyze PSBT
keep bitcoin analyze --psbt unsigned.psbt --network mainnet

# Sign PSBT
keep bitcoin sign --key main --psbt unsigned.psbt --network mainnet

Build the PSBT in a watch-only wallet that imported the exported descriptors, so each input carries its BIP-371 key origin (tap_key_origins). sign derives the key that origin names, applies the BIP-86 key-path tweak, and signs only an input whose scriptPubKey that tweaked key actually owns. An input without the wallet's key origin is left unsigned. The one exception is the key's own underived taproot output, which is signed on mainnet only: it is the same output on every network, so a test-network signer will not spend it. Inputs must use sighash DEFAULT or ALL; a PSBT asking for anything narrower is refused. Change is recognized the same way, within the first 1000 addresses of account 0's change chain; any other output counts as a spend. sign shows each input (whether this key will sign it), the outputs with change marked, the fee, and at most how much leaves the wallet (every output except change, plus the fee), then asks for confirmation. Only --yes skips the prompt; KEEP_YES does not, and without a terminal sign refuses rather than signs.


Threshold Signatures (FROST)

Split keys into t-of-n shares for distributed signing.

Local Operations

# Create new 2-of-3 threshold key
keep frost generate --threshold 2 --shares 3

# Split existing key into shares
keep frost split --key main -t 2 -s 3

# List shares
keep frost list

# Export share (encrypted with passphrase)
keep frost export --share 1 --group npub1...

# Import share
keep frost import

# Sign with local shares
keep frost sign --group npub1... --message <hex>

Network Signing

Coordinate signing across devices over nostr relays:

# Device 2: Start signer node
keep frost network serve --group npub1... --relay wss://bucket.coracle.social

# Device 1: Check online peers
keep frost network peers --group npub1...

# Device 1: Request signature
keep frost network sign --group npub1... --message <hex>

# Device 1: Sign nostr event
keep frost network sign-event --group npub1... --kind 1 --content "Posted via FROST"

A co-signer refuses key-path spends of the group's taproot outputs (signatures that move the group's Bitcoin) unless it opts in. serve --key-spend-to <ADDRESS> --key-spend-max-sats <SATS> --key-spend-max-fee-sats <SATS> --key-spend-max-daily-sats <SATS> opts in unattended, within a policy: on top of checking that the input being signed is the group's own output for the requested path and that the sighash is DEFAULT or ALL, the node requires every output of the spend to pay one of the given destinations, the spend to consume at most the cap, and the fee to be at most the fee cap. Consume covers the outputs plus the fee. The fee needs its own bound because the consumed cap cannot carry it: a cap large enough to permit a real spend is at least the value of that spend's inputs, and within such a cap a request could pay one satoshi to an allowed destination and burn the rest to miners. --key-spend-max-daily-sats adds the bound across spends: the total the spends this node co-signs may consume over any rolling 24 hours, rolling rather than per calendar day so it cannot be doubled by straddling midnight. All four flags are required together, and naming none of them keeps the refusal. Size the fee cap as a real network fee, in the thousands of satoshis, rather than as a share of the balance, because within one spend it is the only bound on what a compromised member can destroy: it can pay an allowed destination a token amount and leave the fee cap to miners.

Two things the daily total does not do. It bounds this node's participation, not the group's spending: a refusal makes the requester fail over to another co-signer, whose total is its own, so divide by the number of co-signers that opt in. And it is held in memory, so it starts empty after a restart; a durable total is follow-up work. It also bounds signatures issued rather than funds at risk, because a signature does not expire: a member can take the full total every day and broadcast everything it has collected at once, so read the number as how much this node is willing to have signed per day. The window is measured on a monotonic clock, which no clock change can wind back, but which does not advance while the machine is suspended, so it is 24 hours of uptime rather than of wall time. And it is a denial-of-service surface that no node-local accounting can close: the amounts come from prevouts the requester asserts, and a co-signer has no chain access to check them, so any share holder can fill the total with one sham request costing it nothing and stop that node co-signing until the window rolls. The destination allowlist is what still bounds where money can go while that is happening. One transaction is charged once however many of the group's inputs it spends, since each input is a separate signing session reporting the same transaction. Change counts like any other destination, so list the group's change address and set the cap to at least what the inputs supply, which the cap being inclusive makes sufficient; the consequence is that spending a large UTXO needs a cap at least that large. The desktop and mobile apps refuse key-path spends until their approval prompts can show the spend. Only co-signers running a version that applies the taproot tweak are asked to sign one.

Threshold-OPRF Vault Unlock (advanced)

Reconstruct a disk-encryption (LUKS) key at boot from a t-of-n Oblivious-PRF quorum across holders (for example an on-prem box, a phone, and a remote replica), so no single device can decrypt the volume. A holder answers an OPRF evaluation only after verifying the requester's measured-boot state by TPM attestation. These are the building blocks the keep-node appliance drives at boot; they can also be run directly.

# 1. One-time provisioning by the dealer/box, with every holder online and
#    serving (step 2) so they receive and seal their shares: generates the OPRF
#    key, splits it t-of-n, distributes the remote shares, and writes the LUKS
#    key plus this box's own share. `--tpm-tcti` is required — holders refuse a
#    share from a dealer they cannot attest.
keep frost network oprf-provision --group npub1... \
  --threshold 2 --total 3 --volume-id vault0 \
  --key-out luks.key --share-out box.share \
  --tpm-tcti device:/dev/tpmrm0

# 2. Each holder runs a node that seals its enrolled share, loads it on the next
#    start, and answers evaluations, verifying the box's attestation first
#    (see "Attestation policy" below):
keep frost network serve --group npub1... \
  --oprf-share-file holder.share --oprf-dealer 1 \
  --attestation-config keep-attestation.toml

# 3. The box requests an unlock at boot, attaching its own TPM quote:
keep frost network oprf-unlock --group npub1... \
  --volume-id vault0 --share-file box.share \
  --tpm-tcti device:/dev/tpmrm0

Attestation policy

A holder only answers an evaluation from a peer whose TPM quote matches a pinned policy (its attestation key and reference PCRs). Author that policy by observing the group's announces, then enforce it:

# The peer to be pinned must be online and attesting (running with `--tpm-tcti`,
# e.g. `keep frost network serve --tpm-tcti device:/dev/tpmrm0`) so its announce
# carries a quote. Then capture the pins (trust-on-first-use; trusted network):
keep frost network attestation-provision --group npub1... --out keep-attestation.toml

# Enforce it — holders refuse OPRF evaluations from unverified peers:
keep frost network serve --group npub1... --attestation-config keep-attestation.toml

# Test/dev only — run with no policy (the node then serves no OPRF evaluations,
# only FROST coordination):
keep frost network serve --group npub1... --insecure-no-attestation

The TPM quote producer (--tpm-tcti) requires a build with the tpm-attestation feature (cargo build --features tpm-attestation); it links the tpm2-tss stack and is off by default.

Point --tpm-tcti at a LOCAL TPM device (e.g. device:/dev/tpmrm0) for a meaningful measured-boot guarantee. A remote or virtual TCTI (e.g. swtpm:host=...) lets an operator self-attest from a TPM they do not physically control, which defeats the purpose of pinning the quote.

Policy Enforcement (Warden)

Integrate with Warden for policy-based signing controls:

# Build with warden support
cargo build --release --features warden

# Sign with policy check
export WARDEN_TOKEN="<jwt>"
keep frost sign --warden-url http://localhost:3000 --group npub1... --message <hex>

# Network sign with policy check
keep frost network sign --warden-url http://localhost:3000 --group npub1... --message <hex>

Policy decisions:

  • ALLOW: Signing proceeds
  • DENY: Signing blocked with reason
  • REQUIRE_APPROVAL: CLI waits for approval workflow (polls up to 5 minutes)

Hardware Signing

Store FROST shares on an air-gapped hardware signer. See keep-esp32 for firmware.

# Test connection
keep frost hardware ping --device /dev/ttyACM0

# List shares on device
keep frost hardware list --device /dev/ttyACM0

# Import share to hardware
keep frost hardware import --device /dev/ttyACM0 --group npub1... --share 1

# Export share from hardware (encrypted backup)
keep frost hardware export --device /dev/ttyACM0 --group npub1... --output backup.json

# Network sign using hardware
keep frost network sign --group npub1... --message <hex> --relay wss://bucket.coracle.social --hardware /dev/ttyACM0

Distributed Key Generation (DKG)

Generate threshold keys without any single party knowing the full private key. Each participant runs independently and coordinates via Nostr relay.

Security: DKG vs Trusted Dealer

Aspect Trusted Dealer (frost generate) Distributed DKG (frost network dkg)
Key exposure Full key exists on one machine Full key never exists anywhere
Entropy source Single machine All participants contribute
Compromise risk Single point of failure Requires threshold breach
Use case Testing/development Production

The trusted dealer approach (keep frost generate) generates the full private key on a single machine. If that machine is compromised during generation, all funds are at risk. Distributed DKG ensures the complete key is never computed: each participant generates their share from independent entropy, so no single device ever holds enough information to reconstruct the key.

# 1. Every participant, on its own device: enroll a per-group signing subkey
#    and send the printed hex pubkey to the coordinator.
keep frost network group-subkey --group mygroup

# 2. Coordinator: publish the signed roster, listing the subkeys in index order.
#    Send the printed Group ID to every participant.
keep frost network group-create --name mygroup --threshold 2 --participants 3 \
  --participant-subkey <hex of participant 1> \
  --participant-subkey <hex of participant 2> \
  --participant-subkey <hex of participant 3> \
  --relay wss://relay.example --publish

# 3. Every participant, at the same time, with its own index:
keep frost network dkg --group mygroup --group-id <Group ID> \
  --threshold 2 --participants 3 --index 1 --relay wss://relay.example

--group is the name the participant used with group-subkey; --group-id pins the coordinator's roster: it is a hash over the group name, threshold and every participant's subkey, so a roster with any other contents is refused. Use a relay that stores kind 31101 events. Hardware DKG over relays is not supported yet.

All participants must run the command within 5 minutes. On completion, each device stores its share and outputs the group public key.


Wallet Descriptor Coordination

Turn a FROST group into a Bitcoin wallet with proper external/internal address chains and optional time-locked recovery tiers, coordinated across signers over Nostr.

# Descriptor from a FROST group (no recovery tiers): BIP-86 receive (/0/*) and
# change (/1/*) chains below the group key, importable into a watch-only wallet.
keep wallet descriptor --group npub1... --network mainnet

# Propose a coordinated descriptor with a recovery tier (preferred: distinct chains)
keep wallet propose --group npub1... --network mainnet \
  --recovery '2of3@6mo' --relay wss://bucket.coracle.social

# Inspect / export a stored descriptor
keep wallet show --group npub1...
keep wallet export --group npub1... --format sparrow

# Announce recovery xpubs to peers, register on a NIP-46 hardware signer
keep wallet announce-keys --group npub1... --xpub 'xpub.../fingerprint/label'
keep wallet register --group npub1... --device 'bunker://...'

# Sign a PSBT spending the group's own addresses (key path), with co-signers over a
# relay, or with threshold shares held in this vault
keep wallet sign --group npub1... --psbt unsigned.psbt -o signed.psbt
keep wallet sign --group npub1... --psbt unsigned.psbt -o signed.psbt --local

# Coordinate a recovery-tier (scriptpath) spend via PSBT
keep wallet spend --group npub1... --recovery-tier 0 --psbt-file unsigned.psbt
keep wallet approve-psbt --group npub1... --session <id> --signer-bunker 'fp:bunker://...'

# List stored descriptors
keep wallet list

Recovery tier syntax is threshold-of-keys@timelock, e.g. 2of3@6mo or 3of5@1y.

wallet sign signs the inputs that spend the group's own outputs on the key path: an address of the stored descriptor (build the PSBT in a watch-only wallet that imported it, so each input carries its key origin), or the key path of a recovery-tier wallet. The spent scriptPubKey is checked against the group's output for that path, not trusted from the PSBT; other inputs are left unsigned, inputs must use sighash DEFAULT or ALL, and every input needs its UTXO. It shows each input, the outputs with change marked, the fee and at most how much leaves the wallet, then asks for confirmation (only --yes skips it). Over a relay it waits for enough co-signers that support key-path spends; each must opt in (frost network serve --key-spend-to <ADDRESS> --key-spend-max-sats <SATS> --key-spend-max-fee-sats <SATS> --key-spend-max-daily-sats <SATS>). An opted-in co-signer verifies the input is the group's own output and the sighash type, and applies its own destination and amount policy, so a spend is signed only if every output pays a destination that co-signer allows, the spend consumes no more than its cap, and the fee is within its fee cap. Within that policy any one member holding a share can spend, once threshold - 1 co-signers have opted in, bounded per spend by that co-signer's caps and across spends by its rolling daily total, which each co-signer keeps for itself rather than sharing. The signed PSBT carries a key-path signature per group input and is not finalized. The group's outputs are the same scripts on every network (only the descriptor's coin type label differs), so a signature made for a test-network PSBT also spends the same outputs on mainnet if the PSBT in fact spends mainnet coins; with a test-network descriptor, wallet sign refuses unless --any-network is passed, and then warns before asking.


Audit Log

Signing and key-lifecycle operations are recorded in a tamper-evident, hash-chained audit log inside the vault.

keep audit list --limit 50      # Recent entries
keep audit verify               # Verify hash-chain integrity
keep audit acknowledge          # Acknowledge the audit warnings the vault reports
keep audit stats                # Summary statistics
keep audit export --output audit.json
keep audit retention --max-days 90 --apply   # Prune old entries

Each entry carries a sequence number inside its hash, one more than the entry before, counted from the first entry the log ever had. Pruning removes only the oldest entries, keeps every remaining entry's hash and sequence number, and records where the kept chain now starts in an entry of its own (counted toward --max-entries), so keep audit verify fails if anything else cut entries from the front or spliced the chain. A log whose chain is broken is never pruned. Changing the data key re-encrypts the log without changing a hash.

The vault database also records the log's latest sequence number and hash, after every entry is written. An unlock that finds the log behind that record (entries cut from the end, or the whole log replaced by an earlier copy, such as the old log a migration kept), or past it without the recorded entry (another log swapped in, such as another node's under a shared data key), still opens the vault, but warns on every unlock (the CLI and the gateway log an error, the desktop app shows a banner that stays), and keep audit verify fails, until keep audit acknowledge, run once you have investigated, shows every pending warning, asks for confirmation (KEEP_YES answers it), and writes them into the log itself; it acknowledges only what it showed. Every unlock that finds the log so before then is counted, with the lowest entry it was found at. Every unlock also checks the chain from its start, so entries removed, changed or spliced in before the last one are reported the same way; acknowledging covers the breaks as that unlock found them, and any other break is reported after. A log longer than 100,000 entries is checked that far and reported as too long until pruning shortens it. The record is written when the vault is created, and stored with its own name inside its encrypted value, so it cannot be moved under another name. A record deleted from the vault database is reported the same way when the log notes one was kept (every unlock entry and every pruning entry does), when the agent freeze epoch's row is there (written when the vault is created and on every unlock), or when a key, FROST share, secret, wallet descriptor or agent credential was created (or a secret updated) after the log's last entry whose time keep set itself (rate-limit trips are passed over: their time comes from a file anyone with the vault folder can write, and is entered no later than the unlock that writes it); otherwise the unlock is taken for the vault's first by a keep that keeps the record (one an older release or an unreleased build made) and quietly writes the first record and freeze epoch. A record that cannot be decrypted or parsed is reported too. Either way it is started again so later changes are still noticed; a failure to read the vault database stops the unlock instead. Pruning notes in the record where the log will end before it replaces the log. A log changed on disk while the vault is open, even padded back to its length, is neither appended to nor pruned nor re-encrypted as the vault's own until the next unlock, which compares it with the record. A log put back together with an earlier copy of the vault database (or with the record and the freeze epoch rows deleted and nothing created since the log's last entry, where a share refreshed or a key used since does not count, and a row that cannot be read is passed over) is not caught this way: that needs a record kept outside the vault, such as the anchors an approver phone keeps (see the agent gateway's approvals).

A vault whose log was written before sequence numbers is migrated the first time it is opened: the old chain is checked, every entry that can be read is written again with sequence numbers, a migration entry records the old log's last hash, its entry count and whether its chain checked out, and the old file is kept beside it as audit.log.v1 (or audit.log.v1.2 and so on when that name is taken). If the old chain failed its check, the vault still opens, but every unlock warns (the CLI and the gateway log an error, the desktop app shows a banner) until keep audit acknowledge, and keep audit verify fails until then; it also says the entries from before the migration cannot be trusted for as long as the log still holds any of them, and once pruning has removed them, only notes the failure, which neither pruning nor a later migration erases. Keep the kept file to investigate; audit.log.v1.failed names it. The failure is recorded in the vault database, so deleting audit.log.v1.failed or the kept file does not stop the warning (a kept file that is gone is reported missing); acknowledging does, and removes audit.log.v1.failed. A data key change re-encrypts kept files too.

Lines that cannot be read never stop the vault from opening: a line that does not decrypt, one that decrypts but that this keep cannot parse (an entry type a newer keep added, or a line copied in from the other log), and one in a newer format alike. An older log keeps every entry that can be read; a log already in the new format keeps the entries before the first line it cannot read, unchanged, and the whole file is kept byte for byte; a log of which nothing can be read under the vault's key is set aside entirely, and reported in one warning that says whether a keep rotate-data-key was under way and unfinished (which leaves the log under the key it replaced) or not (so the file was replaced or damaged), and where the record had the log. Every log set aside before the owner acknowledges is reported, not only the latest. A final line without its newline, an append a crash cut short, is dropped only after a line that reads; otherwise it stays in the kept copy. Each time the old file is kept and the failure recorded as above, and entries lost this way make the sequence number go back. A damaged line before the last is found only when the whole log is read: keep audit verify and pruning then fail, and a data key change sets the log aside. A set-aside a crash kept from the record is found from audit.log.v1.failed on the next unlock and reported; the log is still compared with the record, since anyone who can write the vault folder can write that file. Restoring a backup whose audit entries fail their chain check records the failure the same way, so keep audit verify fails on the restored vault too. The signing audit log (signing_audit.log) keeps its format; one whose last line cannot be read under the vault's key is kept byte for byte as signing_audit.log.unreadable, a new one started, and the owner warned until keep audit acknowledge; a data key change re-encrypts that file too. A data key change sets aside a log that does not end where the vault left it rather than re-encrypting another log as its own.

Back up the vault before upgrading from v0.11.0 or earlier (keep backup): the first open by this release moves the vault to schema 8, and older releases then refuse to open it (vault was created with a newer version of keep) without changing anything. To go back, restore the backup with the older release.


Mobile (NIP-55)

UniFFI library for Android/iOS apps to hold FROST shares and sign via NIP-55 protocol. See keep-android for the Android app implementation.

// Android: Initialize with secure storage
val mobile = KeepMobile(AndroidSecureStorage(context))
mobile.importShare(kshare, passphrase, "phone")
mobile.initialize(listOf("wss://relay.example.com"))

// Handle NIP-55 intents
val handler = Nip55Handler(mobile)
val request = handler.parseIntentUri(intentUri)
val response = handler.handleRequest(request, callerPackage)

Supports get_public_key, sign_event, nip44_encrypt, nip44_decrypt.


Agent SDK

Secure signing for AI agents with constrained sessions.

Python

Not yet published to PyPI. Install from a repo clone (the build backend is maturin):

pip install ./keep-agent-py
from keep_agent import AgentSession, SessionScope, RateLimit

session = AgentSession(
    scope=SessionScope.nostr_only(),
    rate_limit=RateLimit.conservative(),
    duration_hours=24,
)

if session.check_operation("sign_nostr_event"):
    session.record_request()

info = session.get_session_info()
print(f"Requests remaining: {info.requests_remaining}")

A session with Bitcoin operations must name its network, and one that signs PSBTs must also set max_amount_sats; otherwise creating the session fails. Every Bitcoin call uses the session's network, and a call naming a different one is refused. The limit caps what one PSBT takes out of the wallet: every output except recognized change, plus the fee. Allowlist entries must be addresses on the session's network.

scope = SessionScope(
    operations=["sign_psbt", "get_bitcoin_address"],
    network="mainnet",
    max_amount_sats=50_000,
    address_allowlist=["bc1p..."],
)

LangChain:

from keep_agent import AgentSession
from keep_agent.langchain import KeepSignerTool

session = AgentSession()
tool = KeepSignerTool(session=session)

CrewAI:

from keep_agent import AgentSession
from keep_agent.crewai import create_keep_tools

session = AgentSession()
tools = create_keep_tools(session)

TypeScript

Not yet published to npm. Build from a repo clone:

cd keep-agent-ts && npm install && npm run build
import { KeepAgentSession, createNostrScope } from '@keep/agent';

const session = new KeepAgentSession(createNostrScope());
const info = await session.getSessionInfo();

MCP Server (Claude/Cursor)

keep agent mcp runs a stdio MCP signing server bound to one vault key. The server signs under a constrained policy, so the model gets signing capability without ever seeing the private key. Every signature is recorded in the vault's audit log (keep audit) before it is returned, and a signature that cannot be recorded is not returned. Signing stops when the log reaches 90,000 entries, so the log stays readable. To resume, stop the server, export the log (keep audit export) and prune it (keep audit retention --max-entries 10000 --apply).

# Nostr tools only: get_nostr_pubkey, sign_nostr_event and get_session_info
keep agent mcp --key main

# Also get_bitcoin_address and sign_bitcoin_psbt on mainnet, spending at most 50,000 sats
# per PSBT, and only to these addresses (besides the wallet's own change)
keep agent mcp --key main --network mainnet --max-amount-sats 50000 \
  --allow-address bc1p... --allow-address bc1q...

The Bitcoin tools are opt-in. --network enables get_bitcoin_address and binds every Bitcoin tool to that network: a request naming another network is refused. --max-amount-sats also enables sign_bitcoin_psbt; it caps what one PSBT may take out of the wallet, every output except recognized change plus the fee. --allow-address (repeatable) restricts every other output to the listed addresses, which must be addresses on --network.

Add it to your MCP client configuration:

{
  "servers": {
    "keep-signer": {
      "command": "keep",
      "args": ["agent", "mcp", "--key", "main"]
    }
  }
}

Set KEEP_PASSWORD in the server's environment so it can unlock the vault non-interactively. The agent can then read that password, and with it the whole vault. To give an agent keys it never holds, run the agent gateway and connect the client with keep agent connect instead.

Agent Gateway (preview)

keep gateway serve (Linux only) is a daemon that holds the unlocked vault so agents never do. It runs as its own user and refuses to run as root. Each agent gets a credential: a token bound to the uid the agent runs as, with a grant listing the keys, operations, event kinds and Bitcoin limits it may use, request rate limits and an expiry. Requests are refused unless the grant allows them. Requests that need a person's approval wait for one from the owner's phone when approvals are set up, and are refused otherwise. Every answer that returns data, and every signature, is recorded in the vault's audit log before it is returned. Refused tokens all get the same answer, whatever made them invalid.

The owner manages credentials over a second socket, open only to root and one admin uid. While the gateway runs it holds the vault's lock, so management goes through this socket.

Strong mode

The gateway keeps keys from agents only when all of these hold:

  • The gateway runs as its own user, keep-gateway, shared with no agent and no other service (keep serve and keep frost serve run as keep). It refuses root.
  • No agent user has an unattended way to root: no passwordless sudo, no membership in docker, lxd or libvirt, and no write access to the shell startup files of a user who runs sudo.
  • No agent user is in the keep-gateway or keep-admins group, or is the gateway's admin uid.
  • Each agent that must be kept apart from the others runs as its own user. A credential is bound to a uid, and every process running as that uid can use it, so agents that share a user share every grant bound to that user: their grants add up. Giving each agent its own user is optional, but it is the only way to give agents different grants that they cannot use on each other's behalf.

Running the gateway under systemd

contrib/systemd/keep-gateway.service runs the gateway as the keep-gateway user with its vault in /var/lib/keep-gateway/vault. systemd decrypts the vault password from a credential sealed to this machine and hands it to the gateway in a directory only the keep-gateway user can read, so the password is never stored in plaintext and never in an environment variable. It needs systemd 250 or later (Ubuntu 22.04, with 249, is too old), a TPM 2.0, and the TPM libraries systemd loads: systemd-creds has-tpm2 must list +libraries, and on Debian and Ubuntu sudo apt install tpm2-tools brings in any that are missing.

  1. Create the users and groups. Agents reach the gateway through keep-agents, the admin through keep-admins:

    sudo groupadd --system keep-agents
    sudo groupadd --system keep-admins
    sudo useradd --system --user-group --home-dir /var/lib/keep-gateway --shell /usr/sbin/nologin keep-gateway
    sudo useradd --system --create-home --groups keep-agents agent-writer   # one user per agent
    sudo usermod -aG keep-admins "$USER"   # optional: manage it without sudo (log in again for it to apply)
  2. Install the binary and the unit, and create the vault as keep-gateway (it asks for the new vault password):

    sudo install -m 0755 keep /usr/local/bin/keep
    sudo install -m 0644 contrib/systemd/keep-gateway.service /etc/systemd/system/
    sudo install -d -m 0700 -o keep-gateway -g keep-gateway /var/lib/keep-gateway
    sudo -u keep-gateway -H /usr/local/bin/keep --path /var/lib/keep-gateway/vault init
    sudo -u keep-gateway -H /usr/local/bin/keep --path /var/lib/keep-gateway/vault generate --name agent
  3. Seal the vault password to this machine. It is read without echo and never written in plaintext:

    sudo install -d -m 0700 /etc/keep/gateway
    sudo -v   # so sudo does not ask for its password while the vault password is typed
    systemd-ask-password -n "Vault password:" | sudo systemd-creds encrypt \
      --name=vault-password --with-key=host+tpm2 --tpm2-pcrs=7 - /etc/keep/gateway/vault-password.cred

    host+tpm2 needs both this machine's TPM and its host key (/var/lib/systemd/credential.secret, readable by root alone) to decrypt it, so neither a copied disk nor access to the TPM is enough. Whoever takes the whole machine holds both, though: on an unencrypted disk they can read the host key and boot any system Secure Boot accepts to have the TPM decrypt the password, so where theft of the machine matters, encrypt its disk. --tpm2-pcrs=7 binds it to the Secure Boot state: a firmware or Secure Boot update that changes that state stops it from decrypting, and it must be sealed again. Pass --tpm2-pcrs= (empty) to bind it to no state. Seal it again whenever the vault password changes.

  4. Set the gateway's options in a drop-in (sudo systemctl edit keep-gateway), for example:

    [Service]
    ExecStart=
    ExecStart=/usr/local/bin/keep --path /var/lib/keep-gateway/vault gateway serve --admin-uid 1000 --wallet-budget-sats 100000
  5. Start it, and start it at boot. systemctl returns once the vault is unlocked and both sockets listen, or reports the failure:

    sudo systemctl enable --now keep-gateway
    keep gateway status        # as root or the admin uid

A gateway that cannot start tries five times and then stays stopped rather than retrying forever; journalctl -u keep-gateway says why:

  • Decryption failed - wrong password: the sealed password is wrong. Seal it again (step 3), then sudo systemctl reset-failed keep-gateway and start it.
  • Rate limited after repeated unlock failures; try again in Ns: after repeated wrong passwords the vault refuses unlocks for a while, doubling up to five minutes, without trying the password. Wait the N seconds it names, then reset-failed and start it, or it will use up its five tries again.
  • Failed to unseal secret using TPM2 (status 243/CREDENTIALS): the Secure Boot state changed since it was sealed. Seal it again (step 3), then reset-failed and start it.

The gateway never reads the vault password from KEEP_PASSWORD, and refuses to start if it is set: a variable set in a unit is shown to every user by systemctl show. It reads it from the vault-password credential in $CREDENTIALS_DIRECTORY. That directory and the file in it must be owned by root or keep-gateway, not be symlinks, be closed to other users and not writable by their group, be readable by their group only if that group is root's or keep-gateway, and carry no ACL entry for anyone else; the file holds the password on one line. Run outside systemd, it asks for the password on a terminal; another supervisor can set CREDENTIALS_DIRECTORY to a directory it controls that holds the vault-password file.

The unit creates /run/keep-gateway (keep-gateway:keep-agents) and /run/keep-gateway-admin (keep-gateway:keep-admins), both mode 0750, before each start. RuntimeDirectory= cannot do this, since it gives every directory the service's own group. The gateway refuses to start if either directory is not owned by keep-gateway, is open to other users or writable by its group, or carries an ACL entry for anyone but root or keep-gateway, or if the admin directory is open to the agent directory's group.

The unit runs the gateway with no capabilities, no new privileges, a system call filter, no network (PrivateNetwork=; its sockets are in the file system), a read-only system, no access to home directories, and core dumps off; on systemd 255, systemd-analyze security keep-gateway rates it 0.4 (safe). It leaves out PrivateUsers=, which would hide the agents' uids from the gateway, and ProcSubset=pid, which would hide files under /proc/sys the gateway reads.

Running it by hand instead, each socket directory must exist first, owned by keep-gateway, mode 0750 or tighter, and separate from the other (/run is emptied at boot):

sudo install -d -m 0750 -o keep-gateway -g keep-agents /run/keep-gateway
sudo install -d -m 0750 -o keep-gateway -g keep-admins /run/keep-gateway-admin
sudo -u keep-gateway -H /usr/local/bin/keep --path /var/lib/keep-gateway/vault gateway serve --admin-uid "$(id -u)"

--wallet-budget-sats caps what all agents together may take out of one key's wallet in any 24 hours. Mainnet has its own budget, and the test networks share one. The default, 0, refuses every spend.

Managing credentials (the admin commands check that the socket belongs to --gateway-user, by default keep-gateway, before sending anything):

# A Nostr signer for the agent running as uid 1001; the token is written to a new 0600 file
keep gateway issue --name writer --uid 1001 --key npub1... \
  --op get_public_key --op sign_nostr_event --kind 1 --token-out writer.token

# A testnet PSBT signer: 20,000 sats per PSBT, 50,000 in any 24 hours
keep gateway issue --name payer --uid 1002 --key npub1... --network testnet \
  --op get_bitcoin_address --op sign_psbt --per-psbt-sats 20000 --window-sats 50000

keep gateway list
keep gateway freeze <id>      # or --all; unfreeze the same way
keep gateway revoke <id>
keep gateway audit --limit 50

Credentials expire after 30 days by default (--ttl-days). A credential may not be bound to root, the gateway's uid, the vault owner's uid, the admin uid or the overflow uid. Agents that share a uid share every grant bound to it (see Strong mode).

Approvals

Some requests need a person to approve them:

  • signing a Nostr event of a kind that rewrites the owner's state or carries payments, logins or remote signing: kinds 0, 3, 5, 62, 7374 to 7376, 9321 and 37375, and every kind from 10000 to 29999;
  • a PSBT that would take the credential past its --approval-above-sats within 24 hours.

Without approvals these are refused. With them, the gateway asks the owner's phone and waits for its decision. The phone runs an approver app paired with the gateway; the two exchange end-to-end encrypted Nostr messages (NIP-59 gift wraps) over relays, so relays see only that the gateway and the phone are talking. The gateway seals its messages with a channel key kept apart from the vault. The phone holds two keys of its own: a transport key that opens messages and sends freezes, and an approver key that signs decisions.

The phone shows the request in full: the event, or the PSBT with its fee, what leaves the wallet, and the outputs the gateway found to be its own change, each with its derivation path, and what the credential has already spent of its budget. The phone works out the fee from the transaction itself, but it cannot re-derive change addresses, so for which outputs are change, and so for what leaves the wallet, it takes the gateway's word, which no agent can influence. A decision is a signature by the approver key over a hash of exactly that request, and the gateway checks it against the request it is holding before it signs anything, so an approval cannot be moved to another request or to a changed one. Each request takes one decision, the first to arrive.

What waits for an approval:

  • An agent's request as above. The agent's call is answered once the phone decides, or refused when no decision comes within the deadline (--approval-deadline, 30 to 120 seconds, 120 by default). A credential may have one request waiting at a time, and the gateway 16 in all.
  • While any phone is paired, any management change that gives agents more power: keep gateway issue, unfreeze (of one credential or --all), pairing another phone, and keep gateway approver remove. The command prints Waiting for an approver device to approve... and ends with the phone's decision; interrupting it withdraws the request. Freezing, revoking and deleting never wait.

The phone can freeze one credential, or every one, at any time without asking anyone; only a decision signed by an approver key lifts that freeze. A credential whose requests are denied three times within 24 hours is frozen the same way. Every freeze and unfreeze, and deleting a frozen credential, moves the vault's freeze epoch, so an unfreeze approved for an earlier state cannot lift a later freeze. A freeze goes ahead even when the epoch cannot be read (damaged or deleted), starting a new epoch above any earlier one and saying so in the audit log; an unfreeze is refused until the epoch reads again, so freeze once to start a new one. A missing epoch is started again at the next unlock, seeded the same way whether or not agents remain (only a new vault starts at 0), noted in that unlock's audit entry and reported until keep audit acknowledge, since the vault writes it at creation and every unlock and only a deletion removes it. A seeded epoch is never below the latest time the audit log recorded, and carries random low bits so two seeds in the same second differ. Every 15 minutes, and after each freeze or unfreeze, the gateway sends the phone an anchor: where the audit log is, how many spends each wallet's ledger has recorded, and what is frozen. The phone warns when an anchor goes backwards, as it does when the gateway's vault is put back to an earlier copy, and when a freeze it sent is not shown.

No grant may name the channel key or any phone's keys, or list the protocol's own kinds (24250 to 24255), which no agent may sign, approved or not. Every request, decision, freeze, pairing and removal is written to the audit log.

Setting up approvals

The gateway itself has no network. A relay helper, keep gateway relay, runs as its own user, keep-gateway-relay, with network access and nothing else: no vault, no key, no credential. It connects to the gateway's relay socket, /run/keep-gateway-relay/relay.sock, whose directory only that user may enter and on which the gateway accepts only that user, and carries the sealed messages between the gateway and the relays. It can open none of them, so a compromised helper can do no more than a hostile relay: hold messages back.

  1. Create the helper's user:

    sudo useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin keep-gateway-relay

    It must be a user of its own. The gateway refuses to start when the relay user is root, the gateway's own user, the vault's owner or the admin uid, or when a credential is bound to it, and refuses to issue a credential bound to it: an agent running as the relay user could take the single relay connection and cut the gateway off from the phone.

  2. Generate the channel key and seal it to this machine, as the vault password is. It is written only to a pipe, never to a terminal or a file, and its public key is printed on stderr:

    sudo -v
    keep gateway channel-key new | sudo systemd-creds encrypt \
      --name=channel-key --with-key=host+tpm2 --tpm2-pcrs=7 - /etc/keep/gateway/channel-key.cred

    The key never enters the vault, and the gateway refuses one that is a vault key.

  3. Install the helper's unit and the gateway's approvals drop-in, which loads the channel key and creates the relay socket's directory (keep-gateway:keep-gateway-relay, mode 0750):

    sudo install -m 0644 contrib/systemd/keep-gateway-relay.service /etc/systemd/system/
    sudo install -d -m 0755 /etc/systemd/system/keep-gateway.service.d
    sudo install -m 0644 contrib/systemd/keep-gateway-approvals.conf /etc/systemd/system/keep-gateway.service.d/approvals.conf
  4. Name the relays in the gateway's own drop-in (sudo systemctl edit keep-gateway), at least one and preferably two or three, then restart it and start the helper:

    [Service]
    ExecStart=
    ExecStart=/usr/local/bin/keep --path /var/lib/keep-gateway/vault gateway serve --admin-uid 1000 --wallet-budget-sats 100000 \
      --approval-relay wss://relay.example.com --approval-relay wss://relay.example.org
    sudo systemctl daemon-reload
    sudo systemctl restart keep-gateway
    sudo systemctl enable --now keep-gateway-relay
  5. Pair the phone. Root pairs the first one:

    sudo keep gateway approver pair --label "Phone"

    It shows a pairing code, as a QR code and as text, valid for ten minutes and for one phone. Scan it with the approver app; the command then shows the fingerprint the phone shows. Answer y only if the two match. From then on the requests above wait for the phone. keep gateway status shows whether approvals are on and how many phones are paired; keep gateway approver list lists them.

Once a phone is paired, the gateway refuses to start without the same channel key, since the phone trusts no other. A lost channel key is handled like a lost phone, below.

Managing phones, as root or the admin uid:

keep gateway approver list                 # label, fingerprint and keys of each phone
keep gateway approver pair --label "Tablet"   # a further phone: a paired one must approve it
keep gateway approver remove <transport>   # a paired phone must approve
sudo keep gateway approver reset           # root only: remove every phone at once

reset is for a lost phone with no other left to approve its removal. It removes every phone without asking one, writes that to the audit log, forgets the channel key and turns approvals off until a phone is paired again; anything waiting is refused. Since it needs no phone, it needs root. When the gateway cannot start (the channel key was lost, the TPM was cleared, the vault moved to another machine), stop it and reset on the vault directly; this asks for the vault password:

sudo systemctl stop keep-gateway
sudo -u keep-gateway keep --path /var/lib/keep-gateway/vault gateway approver reset --offline

Then generate a new channel key (step 2), start the gateway and pair again.

journalctl -u keep-gateway-relay shows the helper reaching the gateway and the relays. While it is down, messages to the phone wait for it, and requests time out at their deadline.

What the agent sees

get_session_info tells the agent whether approvals are on and the deadline: "approvals": {"enabled": true, "deadline_secs": 120}. A request that waits is answered like any other, only later, so the MCP client must let a tool call run longer than the deadline: about 130 seconds with the default deadline (keep agent connect itself waits 150 seconds). Client timeouts are not well documented; what follows comes from user reports, not vendor documentation, so check your client. Claude Code is reported to wait that long for a stdio server unless MCP_TOOL_TIMEOUT (in milliseconds) is set lower. Some clients are reported to give up on a tool call after 60 seconds with no setting to wait longer; for those, shorten the deadline with --approval-deadline 50. A client that gives up and sends notifications/cancelled withdraws the request, and the bridge passes that on; one that gives up without it leaves the request waiting, and an approval then still signs it, with the answer going nowhere. A request that is not approved is answered with a tool error (isError) whose text says why:

Text Meaning
denied by the approver The phone denied it.
no approval within N seconds No decision came within the deadline.
canceled: frozen, canceled: revoked The credential was frozen, revoked, deleted or expired while it waited.
canceled: the request was withdrawn The client sent notifications/cancelled for it, or closed the connection.
canceled: the gateway is stopping, canceled: no approver device is registered, canceled: the approver devices were reset The gateway stopped, or no phone is left to decide.
an approval for this credential is already pending One request per credential waits at a time.
too many requests are waiting for an approval; try again later The gateway already holds 16.
the request is too large to approve The request is larger than the phone accepts.
needs approval, which this gateway cannot obtain yet: ... Approvals are not set up, or no phone is paired.

Secrets

An agent can call an HTTPS API with a token from the vault without ever holding the token. The grant names the secret and exactly where it may go: a host, a port, the methods and a path prefix, and the header the token is sent in. The agent asks the gateway to send the request; the gateway adds the token, sends it only where a rule allows, and returns the response, refusing it if it carries the token back. The token never reaches the agent, its model provider, or anything the agent can read.

Requests go out through inject workers, never from the gateway itself, which has no network. systemd starts a fresh worker for each request, under a uid of its own, with no vault: it resolves the host, refuses an address that is not globally reachable (any private, loopback, link-local or otherwise special address in the answer refuses the whole lookup), connects only to the addresses it checked, verifies the server against the root certificates built into keep (not the system's), sends the request and exits. A hostile response or DNS answer that compromised a worker would get only that one request's token, and could not reach later ones.

Setting up secrets
  1. Add the secret with the gateway stopped (the gateway holds the vault's lock while it runs). The value is read from standard input:

    sudo systemctl stop keep-gateway
    sudo -u keep-gateway -H /usr/local/bin/keep --path /var/lib/keep-gateway/vault secret add --name "deploy token" --kind api-token

    A secret sent in a header must be an api-token, password or generic secret (not a note), not threshold-sealed, 12 to 2,048 bytes, and visible ASCII (spaces and tabs only inside it, and only where a rule's format leaves the whole header value without one at either end); issuing a credential checks this.

  2. Install the worker units and the drop-in, and add --inject-socket to the gateway's ExecStart= in the drop-in that sets its other options:

    sudo install -m 0644 contrib/systemd/keep-gateway-inject.socket contrib/systemd/keep-gateway-inject@.service /etc/systemd/system/
    sudo install -m 0644 contrib/systemd/keep-gateway-inject.conf /etc/systemd/system/keep-gateway.service.d/inject.conf
    sudo systemctl edit keep-gateway   # ExecStart=/usr/local/bin/keep ... gateway serve ... --inject-socket
    sudo systemctl daemon-reload
    sudo systemctl enable --now keep-gateway-inject.socket
    sudo systemctl start keep-gateway

    The socket, /run/keep-gateway-inject/worker.sock, is root's and open to the keep-gateway group; the gateway checks that its directory and the process serving it are root's before it sends a token. Nothing but the gateway's user may be in the keep-gateway group. Each worker runs with DynamicUser= under a name unique to its connection, so no two share a uid; while injection is on, no credential may be bound to a uid in systemd's dynamic range (61184 to 65519).

  3. Issue a credential with the http_request operation and a file listing its secrets. A secret is given by its id or by name; the gateway resolves names, and refuses a name no secret has or several share (with one answer for both):

    [
      {
        "secret": { "name": "deploy token" },
        "http": [
          {
            "host": "api.example.com",
            "port": 443,
            "methods": ["GET", "POST"],
            "path_prefix": "/v1/deployments",
            "header": "authorization",
            "format": "Bearer {}",
            "agent_headers": ["idempotency-key"]
          }
        ]
      }
    ]
    keep gateway issue --name deployer --uid "$(id -u agent-writer)" --op http_request --secrets-file secrets.json --token-out deployer.token

    With a phone paired, the phone shows each secret by name, kind and id, and every rule in full, before the credential is issued; a secret renamed, deleted or sealed while the request waits makes the issue fail.

A rule:

  • host: a DNS name with a dot, lowercase, in ASCII form (an internationalized name in its xn-- form), never an IP address.
  • port: 1 to 65535.
  • methods: any of DELETE GET HEAD OPTIONS PATCH POST PUT, in that order, each once.
  • path_prefix: starts with /, is more than /, at most 256 characters, with no %, ;, empty segment, . or ... A path matches when it equals the prefix, starts with it when the prefix ends in /, or continues it after a /: /v1 matches /v1 and /v1/x but not /v1x.
  • header: the lowercase header the secret is sent in, without _, and not one that changes routing, framing or encoding.
  • format: the header's value, at most 128 printable ASCII characters, with exactly one {} that the secret replaces.
  • agent_headers: up to 8 more lowercase headers the agent may set, in order. Every rule already lets it set Accept, Content-Type, User-Agent and the If- headers.

A secret holds at most 4 rules, sorted by host, port and prefix, no two of which could match one request; a grant names at most 8 secrets.

Choose the narrowest prefix that does what the agent needs. A rule constrains only the method and path a request names: on an API that routes by query, body or GraphQL operation, it allows whatever that path allows. And if the token can create other credentials (an API key or OAuth token endpoint under the prefix), the agent can make one keep never sees, which outlives freezing and revoking the agent's credential. Leave such endpoints outside every prefix. The host a rule names receives the token by design: keep cannot stop a hostile host from passing it on.

Using secrets

An agent with secrets is offered list_secrets, which lists each granted secret's id, name, kind and rules, an SSH key's public key, and whether it can be used now (never its value), and, with http_request, the tool of that name:

{"secret": "<id>", "method": "POST", "url": "https://api.example.com/v1/deployments", "headers": {"Content-Type": "application/json"}, "body": "{\"ref\":\"main\"}"}

The gateway refuses the request unless the first rule matching its host, port, method and path allows it, and:

  • the URL is https, written exactly as it parses (lowercase host, no default port, no dot segments, nothing the parser would change), with no user information or fragment, no ;, and no percent-encoding in the path but of space, ", <, >, ^, `, {, | and } (so no non-ASCII path, which a server could normalize into a delimiter);
  • no query parameter, form field or top-level JSON key is a method override (_method, however escaped or spelled); every body is read as a form and as JSON whatever its Content-Type, since servers disagree on which they parse, and a body that starts with { but is not valid JSON, is multipart, starts with a byte order mark, holds NUL, or is declared in a charset other than UTF-8 or US-ASCII is refused;
  • every header is one the rule allows, at most 16 of them and 8 KiB together, each once, with a visible ASCII value; the body is at most 256 KiB.

The use is written to the audit log before the token leaves the gateway (agent_served, naming the secret and giving 128-bit SHA-256 prefixes of the URL and body, then the method, host, port and path; never the token); if it cannot be written, nothing is sent. The worker sends the request with Accept-Encoding: identity, follows no redirect and uses no proxy, and has 30 seconds. The answer is:

{"status": 200, "headers": {"content-type": "application/json"}, "body": "...", "body_base64": false, "truncated": false}

Only Content-Type, Content-Encoding, Location and Retry-After come back (never Set-Cookie). A body that is not UTF-8 comes back in base64, and one over 256 KiB is cut and marked truncated. A redirect comes back as it is, its Location included, for the agent to request again if a rule allows. A compressed response, or one under any transfer coding but chunked, is refused. A response that carries the token in any common encoding (as is, percent-, JSON- or HTML-escaped, base64 or hex, as UTF-16 or UTF-32, broken into lines, and the decoded pair of a Basic credential), or its first or last half (at least 16 bytes), anywhere in what was read, including past the cut, is refused, recorded against the credential, and freezes it. An endpoint that reflects what the agent sends lets it test guesses at a short value, a correct guess coming back as secret in response; the freeze stops it there, but each credential granted the secret allows one such guess, so do not grant a guessable password. A credential has one request in flight at a time, a uid two, and the gateway four. A freeze or revoke answers a request in flight at once and stops its worker, though a request already sent may have reached the host.

A request that got no response is answered with one of these, never the underlying error:

Answer Meaning
denied: request not allowed The grant or a rule does not allow the request. Every such refusal reads the same; the reason is in the audit log (keep gateway audit).
unavailable The gateway was started without --inject-socket, or no worker could be reached.
busy Too many requests are in flight.
dns The host did not resolve.
address refused The host resolved to an address that is not globally reachable.
connect The connection failed.
tls The server's certificate was not valid for the host under keep's built-in roots.
timeout No complete response within the time allowed.
encoded response The response was compressed.
secret in response The response carried the secret. The credential is now frozen.
http The response could not be read.
canceled A freeze, revoke or the gateway stopping withdrew the request.

Older keeps cannot use a credential with secrets: an older gateway reports that it cannot read it and refuses its token until it is deleted.

SSH keys

An agent can log in over SSH, and sign git commits, with a key that never leaves the vault. keep generates the key; the agent's SSH client talks to keep agent ssh-agent, a local SSH agent that holds no key and passes each signature request to the gateway, which signs only what the key's rule allows:

  • Logins to a host whose key the rule names, as a user it names. OpenSSH 8.9 and later bind the agent connection to each SSH session with the host's signature over the session; the gateway checks that signature on every request, and signs only a host-bound login request (publickey-hostbound-v00@openssh.com) for that session, that user, this key and that host key. The server checks the host key in the request is its own, so a signature made for one host is useless to any other, even to someone holding a granted host's private key. A client that sends no binding (OpenSSH before 8.9), a connection bound more than once or bound for agent forwarding, and a server that does not offer host-bound logins are refused. OpenSSH 8.9 and later servers and GitHub offer them; GitLab and Bitbucket did not when this was written, so an agent cannot log in to them with a vault key yet.
  • SSHSIG signatures (ssh-keygen -Y sign, which git uses for SSH-signed commits) in a namespace the rule names, only on a connection no SSH session is bound to. OpenSSH 8.9 and later bind every agent connection they forward, so a host the agent forwards its SSH agent to with them gets none; a client that forwards without binding (an older OpenSSH, and some SSH libraries) passes unbound connections on, and the host at the other end can then get signatures within the rule. Do not let the agent forward its SSH agent (ssh -A) except with OpenSSH 8.9 or later.
Setting up an SSH key
  1. Generate the key with the gateway stopped. It prints the public key, as authorized_keys takes it, and keep secret ssh-pubkey "<name>" prints it again:

    sudo systemctl stop keep-gateway
    sudo -u keep-gateway -H /usr/local/bin/keep --path /var/lib/keep-gateway/vault secret ssh-keygen --name "deploy key"
    sudo systemctl start keep-gateway

    Add the public key where the agent should log in (a server's authorized_keys, a forge's deploy keys) or where its signatures are checked (git's allowed_signers). Importing an existing key is not supported.

  2. Issue a credential with the ssh_sign operation and a secrets file naming the key and its rule. Host keys are written as known_hosts writes them (type and base64, no comment: cut -d' ' -f1,2 of a host's .pub file or of ssh-keyscan's output, checked against a fingerprint from a trusted source); ssh-ed25519 and ecdsa-sha2-nistp256 host keys are accepted:

    [
      {
        "secret": { "name": "deploy key" },
        "ssh": {
          "users": ["git"],
          "host_keys": ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"],
          "sshsig_namespaces": ["git"]
        }
      }
    ]
    keep gateway issue --name pusher --uid "$(id -u agent-writer)" --op ssh_sign --secrets-file ssh.json --token-out pusher.token
    sudo install -D -m 0600 -o agent-writer pusher.token ~agent-writer/.config/keep/pusher.token
    rm pusher.token

    users and host_keys go together (both or neither), sshsig_namespaces may stand alone, and each lists at most 8, sorted, each once. A user is letters, digits, ., _ or -; a namespace may also hold @. With a phone paired, the phone shows each host key by type and SHA256: fingerprint.

  3. Run the SSH agent as the agent's user (here agent-writer), with the token file it owns, in a directory only that user can enter, and point the agent's SSH clients at it:

    install -d -m 700 ~/.keep-ssh
    keep agent ssh-agent --token-file ~/.config/keep/pusher.token --listen ~/.keep-ssh/agent.sock &
    export SSH_AUTH_SOCK=~/.keep-ssh/agent.sock
    ssh-add -L                       # the granted key
    git push                         # logs in with it
    git -c gpg.format=ssh -c user.signingkey="key::$(ssh-add -L | head -1)" commit -S -m "..."

    The socket is created with mode 0600 and serves only its own user. Like keep agent connect, it refuses to run as root or with KEEP_PASSWORD set.

Every signature is written to the audit log before it is returned (agent_served labeled ssh_sign, naming the key, then the user and the host key's fingerprint, or the namespace, and a hash of the data); every refusal is recorded with its reason, and the agent is told only denied: request not allowed. Each signature, and each listing of the keys (the SSH agent lists them at most once a minute), counts against the credential's request limits and its daily audit budget like any other request. A freeze or revoke stops new signatures at once, but cannot end an SSH session already logged in. A host that authenticates with a host certificate rather than a plain key is refused.

Older keeps cannot read an SSH key: an older keep secret list fails while the vault holds one, an older keep cannot restore a backup holding one, and an older gateway refuses a credential granting one (serving every other). Remove the key with this release (keep secret rm) before going back to an older keep.

Connecting an MCP client

keep agent connect lets a stock MCP client (Claude, Cursor, any client that runs a stdio server) use the gateway. It speaks MCP on stdio and passes each request to the gateway's agent socket with the agent's token. It needs no vault and no password.

Give the token to the agent's user in a file only that user can read, then remove your copy:

sudo -u agent-user mkdir -p -m 0700 /home/agent-user/.config/keep
sudo install -m 0600 -o agent-user writer.token /home/agent-user/.config/keep/agent.token
shred -u writer.token

Then add the bridge to the client's configuration (for Claude and Cursor, under mcpServers). The client itself must run as the agent's user: the bridge runs as whoever starts it, and the gateway checks that uid.

{
  "mcpServers": {
    "keep": {
      "command": "keep",
      "args": ["agent", "connect", "--token-file", "/home/agent-user/.config/keep/agent.token"]
    }
  }
}

--socket (default /run/keep-gateway/agent.sock) and --gateway-user (default keep-gateway) point it at another gateway.

The bridge protects the token:

  • It reads the token only from the file, never from an argument or an environment variable, since MCP configurations are often committed. The file must be a regular file owned by the user running the bridge, with no access for group or others (mode 0600 or 0400), and not a symlink. Every keep command refuses to run if any argument holds a token.
  • Before sending anything it checks that the socket's directory belongs to the gateway's user and that no one else can write to it, and that the gateway's user is the one serving the socket. It checks again on every new connection, so the token never reaches a socket someone else put in its place. If the socket fails these checks when the bridge starts, it exits.
  • It refuses to run with any of KEEP_PASSWORD, KEEP_NEW_PASSWORD, KEEP_HIDDEN_PASSWORD, KEEP_DURESS_PASSWORD, KEEP_NSEC, KEEP_STORAGE_KEY, KEEP_WEB_AUTH_TOKEN or KEEP_STATE_IDENTITY set, or the _FILE form of KEEP_PASSWORD, KEEP_STORAGE_KEY, KEEP_WEB_AUTH_TOKEN or KEEP_STATE_IDENTITY, since the agent can read its own environment, and it refuses to run as root, which can hold no credential.
  • It never prints or logs the token, and keeps the process out of core dumps.

The bridge sends one request at a time over one connection. It keeps running when the gateway closes that connection (after 10 idle minutes, after three refused requests in a row, or when the gateway restarts) and connects again for the next request, so the client's session survives. If the gateway is not running yet when the client starts the bridge, the bridge starts anyway and answers -32010 until it is. It never sends a request twice if the first may have been carried out:

Error code Meaning
-32001 The gateway refused the request: the token is unknown, revoked, frozen or expired, or was presented from another uid; the agent's uid sent too many requests; or the gateway is stopping. Every refusal reads the same. A notification (a message with no id, such as notifications/cancelled) is never answered, refused or not.
-32002 The credential is over its per-minute, per-hour or per-day request limit, or the gateway is tracking too many credentials.
-32010 The request was not sent, or the gateway turned it away unread: the gateway is stopped or restarting, the socket failed the checks above, or the agent's uid already holds as many connections as the gateway allows (8 open connections by default; uids no credential is bound to share one pool of 8), or the gateway holds its most connections in all (128 by default).
-32011 The request was sent and no answer came: the connection closed, no part of the answer came for 150 seconds, or the answer was not for this request. The request may have been carried out (a signature may be recorded), so the bridge did not send it again.

Notifications from the client are not sent to the gateway, with one exception: notifications/cancelled for the request the bridge is waiting on, which withdraws that request if it is waiting for an approval.

Other clients can talk to the agent socket directly: one JSON object per line, {"token": "keep_agt_...", "message": <JSON-RPC request>}, speaking MCP (initialize, tools/list, tools/call, ping).


AWS Nitro Enclaves

Hardware-isolated signing, keys never leave enclave memory.

# Check enclave status
keep enclave status

# Verify attestation
keep enclave verify

# Generate key in enclave
keep enclave generate-key --name agent

# Import from vault to enclave
keep enclave import-key --name agent --from-vault mykey

# Sign message
keep enclave sign --key agent --message <hex>

# Sign PSBT
keep enclave sign-psbt --key agent --psbt tx.psbt --network testnet

Local testing (insecure, dev only):

keep enclave generate-key --name test --local
keep enclave sign --key test --message <hex> --local

See ENCLAVE.md for deployment.


Hidden Volumes

Plausibly deniable storage, hidden volume is cryptographically undetectable:

# Create vault with hidden volume
KEEP_PASSWORD="outer" KEEP_HIDDEN_PASSWORD="hidden" keep --hidden init

# Access outer (decoy) volume
KEEP_PASSWORD="outer" keep list

# Access hidden volume
KEEP_PASSWORD="hidden" keep --hidden list

Configuration

Environment Variables

Variable Description
KEEP_HOME Custom vault path (default: ~/.keep). Must be absolute. Equivalent to --path
KEEP_PASSWORD Vault password (avoids interactive prompt)
KEEP_HIDDEN_PASSWORD Hidden volume password (with --hidden)
KEEP_YES Auto-confirm non-destructive prompts in scripts (not keep bitcoin sign, which needs --yes)
WARDEN_TOKEN JWT for Warden API authentication (requires --features warden)

KEEP_PATH is not used by the CLI; it configures the vault path for the keep-web co-signer only. For the CLI, use KEEP_HOME or --path. See keep-web/README.md for the co-signer's variables.


Troubleshooting

mlock warning at startup. Keep locks secret memory with mlock(2). On systems with a low RLIMIT_MEMLOCK, locking can fail and Keep logs a warning but continues with degraded protection. Raise the limit (ulimit -l) or pass --no-mlock to silence it deliberately.

keep export refuses to run. Raw nsec export is interactive-only by design: it requires a real TTY on stdin and stderr and refuses when automation variables (KEEP_YES / KEEP_PASSWORD) are set. Run it directly in a terminal.

Forgot which volume / hidden volume not appearing. Hidden volumes are unlocked by password: pass --hidden with the hidden password. There is no way to detect or recover a hidden volume without its password. See Hidden Volumes.

KEEP_HOME must be an absolute path. KEEP_HOME rejects relative paths. Use a full path, e.g. KEEP_HOME=/home/you/.keep-work.

Network FROST signers cannot find each other. All participants must use the same --group and at least one shared --relay, and (for DKG) run within the coordination window. Check liveness with keep frost network peers --group npub1....