This guide sets up nodkey from the start: the broker on a trusted machine, a Telegram bot for the approvals, the secret stores, and the client on the machine where the AI agent works.
DESIGN.md says how nodkey works, and THREAT-MODEL.md says what it protects against. Please read section 5 of the threat model before you rely on nodkey.
The examples use these names. Use your own names instead.
| name | what it is |
|---|---|
broker-host |
the trusted machine that runs the broker |
192.0.2.10 |
the address of the broker host on your private network |
laptop |
the agent machine, and the name of its client key in the broker config |
web-1, web-2 |
SSH hosts that the agent may log in to |
deploy |
the remote user on these hosts, and the name of the broker's SSH key |
github-token |
a secret that the agent may ask for |
123456789 |
the Telegram user ID of the approver: you |
Run the commands for the broker host as root, for example in a shell
from sudo -i, on its console or from a trusted computer
(section 1). Run the commands for the agent
machine as the user who runs the agent. Only the install of nodkey is
different: run it as root on each host, also on the agent machines, from
a root shell that does not come through the agent's account
(section 2).
- Before you start
- Get nodkey
- The broker host
- The Telegram bot
- Stores
- SSH keys and the broker's known_hosts
- The config file
- The systemd unit
- Account and host safety
- Start the broker
- A client on the agent machine
- Check the audit log
- Upgrades and key changes
- When something does not work
You need:
- A broker host. A Linux machine that is always on, with systemd 250
or newer (
systemctl --version; for example Debian 12, or Ubuntu 24.04) and cgroup v2. No AI agent runs on it, and only you have root there. It uses no swap, or only encrypted swap, and no hibernation (section 9). If it has a TPM2 chip, the keys of nodkey can be bound to it. - One or more agent machines. The Linux machines where the agent
works. For SSH logins, they need OpenSSH 8.9 or newer (
ssh -V). - A private network between them, such as your LAN or a VPN, and a fixed address of the broker host on it. The clients connect to one TCP port of the broker: 7743 in the examples.
- Telegram on your phone. This Telegram account approves the requests.
- A store for the secret values: Bitwarden Secrets Manager (
bws), or a separate account of the Bitwarden Password Manager (bw). If you use nodkey only for SSH logins, you need no store.
The broker host is the root of trust. Root on it, or a copy of its memory, gives every key and token (THREAT-MODEL.md section 5, item 5). DESIGN.md section 4 shows how the parts fit together.
Never log in to the approving Telegram account on a machine where an agent runs. Whoever can use that account can approve (section 9).
On a machine where an agent runs, never log in to a Bitwarden account that can read the secrets. Whoever can use such an account can read them without nodkey (section 9).
The agent machine is not trusted. The agent may read its clipboard, its
terminals, its browser and its files. A token that passes through it
may be the agent's from then on. With the bws token alone, the agent
could read every secret of its project, without a tap (THREAT-MODEL.md
section 5, item 8). So:
- Do the setup of the broker host on its console, or from a trusted computer where no agent runs. Do not log in to the broker host from an agent machine.
- Make and copy tokens and passwords only on that trusted computer,
or on your phone: the bot token, the
bwsaccess token, the master password of each Bitwarden account that can read the secrets, and the API key of abwaccount. Never let them pass through an agent machine: not its clipboard, not its terminal, not its browser. Do not save them in a password manager that is open on an agent machine. - Keep no way to get root on the broker host on an agent machine:
no SSH key for the broker host, no SSH agent or
ControlMastersocket that reaches it, no saved password. - The user of the agent never downloads, builds or copies the nodkey binary, on any machine: the agent could change the file before it is installed. Install nodkey as root on each host, with the installer. Or copy it straight from a trusted machine where you built or downloaded it, not through an agent machine, and check its SHA-256 on the broker host (section 2).
Install the same version of nodkey on the broker host and on each agent
machine, as /usr/local/bin/nodkey, owned by root. When the versions
differ, nodkey says so (section 13). Do it
as root on each host, never as the user of the agent. The agent can
change what its own user downloads, builds or copies
(section 1). Get the root shell in a way that
does not go through the agent's account:
- On the broker host: on its console, or from a trusted computer, as for the rest of the setup. Never set it up or update it through an agent machine.
- On an agent machine: on its console, or with SSH as an admin user
from a trusted computer. If you can, do not type
sudoin the terminal where the agent works, or in the agent's account. The agent can put its ownsudothere (a program of that name earlier inPATH, an alias or a function), or type into that terminal.
This has a limit. On an agent machine that the agent controls, nodkey cannot keep its own client safe. But nodkey's promises come from the broker, which trusts nothing from the client: a changed client binary gets no more than what you approve on the phone (THREAT-MODEL.md T20).
There are two ways to install: the installer, or by hand.
install.sh in the repository installs the newest release, or the
version of --version X.Y.Z. It needs curl, tar, gzip,
sha256sum, install, mktemp, stat and uname, and no GitHub
login. Before it installs anything, it checks the SHA-256 and the build
attestation of the release, and that the new nodkey says it is the
version that you want
(DESIGN.md section 18). If a
check fails, it installs nothing. It restarts nothing. As root, it also
refuses a directory or a file that another user than root can change:
/usr/local/bin, the old nodkey in it, and the temporary directory
(TMPDIR, or /tmp).
The first time, on each host: download the script as root, read it, and run it as root:
sudo curl --proto '=https' --tlsv1.2 -fsSL -o /root/install.sh https://raw.githubusercontent.com/code6x/nodkey/main/install.sh
sudo less /root/install.sh
sudo sh /root/install.sh
nodkey versionOn the broker host, where no agent runs, the short form works too. But it runs the script before you can read it:
curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/code6x/nodkey/main/install.sh | sudo shTo check the attestation, the installer downloads gh 2.102.0, and
checks it against the SHA-256 in the script. To use a gh that is
installed already, 2.93.0 or newer, add --gh /usr/bin/gh. Only root may
be able to change that gh and its directories: root runs it. The
installer needs the Sigstore bundle of the attestation in the release,
nodkey_<version>.sigstore.json. 0.1.0 has none: install 0.1.0 by hand.
When that version is installed already, the installer does nothing. It
does not check the installed file then. To replace it with a checked
copy, add --reinstall.
Later, nodkey update runs the copy of the installer that is built into
the installed nodkey (section 13). It takes
--version, --gh and --reinstall too.
Use a trusted machine for this, never an agent machine (section 1). Get nodkey from a release, or from the source.
From a release. Download the tarball for your machine, such as
nodkey_0.1.0_linux_amd64.tar.gz (nodkey_0.1.0_linux_arm64.tar.gz on
64-bit ARM), SHA256SUMS, and the bundle nodkey_<version>.sigstore.json
if the release has one, from the
release page. Check it, as
docs/releasing.md says, with all the
steps there. From gh 2.96.0 on, gh release download needs no GitHub
login. gh attestation verify needs none with --bundle, but needs one
without it, such as for 0.1.0. Then take the file nodkey out of the
tarball:
tar -xzf nodkey_0.1.0_linux_amd64.tar.gz nodkeyFrom the source. Build nodkey with Go 1.26 or newer. Never build it on an agent machine: the agent could change the build.
git clone https://github.com/code6x/nodkey.git
cd nodkey
CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=$(git rev-parse --short HEAD)" -o nodkey ./cmd/nodkeyThe -ldflags part is optional. It puts the commit into
nodkey version and into the broker_started entry of the audit log.
Then, for both ways: copy the file nodkey straight from the
trusted machine to each machine, never through an agent machine
(section 1). On the broker host, check that it
is the same file: sha256sum nodkey must print the same SHA-256 as on
the trusted machine. Then install it, and check it:
install -o root -g root -m 0755 nodkey /usr/local/bin/nodkey
nodkey versionOn a terminal, nodkey version of a release also asks GitHub whether a
newer release is out, and says so in one more line. --offline skips
that.
The broker runs as a system user of its own, nodkey:
useradd --system --home-dir /var/lib/nodkey --shell /usr/sbin/nologin nodkey
install -d -o root -g root -m 0755 /etc/nodkey
install -d -o root -g root -m 0700 /etc/nodkey/credentials /run/nodkey-setup/etc/nodkeyholds the config file and the broker'sknown_hosts. Only root may change them. The broker only reads them./etc/nodkey/credentialsholds the encrypted credentials. Only root reads them: systemd opens them for the broker at each start./run/nodkey-setupis a work directory in memory:/runis a tmpfs. Plain keys and tokens stay there only for a moment. They do not reach the disk, unless the machine moves them to swap. So before you make any key, make sure that the machine has no swap, or only encrypted swap (section 9). The directory is gone after a reboot; then make it again with the same command./var/lib/nodkeyis the state directory: the audit log, and a home directory for each store. systemd makes it at the first start (StateDirectory=in the unit). For some store steps, you make it earlier by hand (section 5).
Every key, token and password of the broker is a systemd credential: a
file that systemd-creds encrypted. At each start, systemd decrypts the
credentials and gives them to the broker in $CREDENTIALS_DIRECTORY, a
directory in memory that only the broker can read. The config names
each one as ${CREDENTIALS_DIRECTORY}/NAME
(DESIGN.md section 11).
Check whether the machine has a TPM2 chip that systemd can use:
systemd-creds has-tpm2If the first line of the output is yes, bind the credentials to the
TPM2 chip and to this installation:
WITH_KEY=host+tpm2Otherwise, use the key of this installation only:
WITH_KEY=hostWithout the TPM2 chip, anyone with a full copy of the disk can decrypt
the credentials: the key is in /var/lib/systemd/credential.secret. So
can anyone with a backup that holds both that file and the credentials.
With host+tpm2, they also need the TPM2 chip of this machine. On a
disk without encryption, systemd-creds warns that this file "is not
located on encrypted media"; that is the risk just described. Set
WITH_KEY again in each new shell; the commands below use it.
Before systemd 258, host+tpm2 also binds each credential to PCR 7 of
the TPM2 chip: the state of Secure Boot. A firmware update or a change
of the Secure Boot settings can change PCR 7. Then no credential can be
decrypted any more, and systemd does not start the broker
(section 14). That fails closed, but
you must make the credentials again. If you did not update the firmware
or change Secure Boot, find out why before you make new credentials:
someone may have changed how the machine starts. systemd 258 and newer bind to no
PCR by default. To get that on an older systemd, add --tpm2-pcrs=
(with nothing after =) to each systemd-creds encrypt. Then the
credentials survive such updates, but the TPM2 chip no longer checks
how the machine was started.
Each credential has a name. systemd-creds writes the name into the
file, and systemd refuses the file under another name. The example
config and the example unit use these names:
| name | what it holds | made in |
|---|---|---|
broker-tls-key |
the TLS key of the broker | this section |
telegram-token |
the token of the bot | section 4 |
bws-token |
the access token of a Secrets Manager machine account | section 5 |
bws-token-backup |
the access token of a second machine account | section 5 |
bw-master-password |
the master password of a separate Bitwarden account | section 5 |
ssh-deploy |
the SSH key deploy |
section 6 |
There are two ways to make a credential:
-
From a file, such as a key. Then remove the plain file:
systemd-creds encrypt --with-key="$WITH_KEY" --name=NAME FILE /etc/nodkey/credentials/NAME.cred -
From what you type, for a token or a password.
systemd-ask-passwordshows a*for each character, never the text, and the value never goes to a file, a command line or the shell history. It waits at most 90 seconds.-nleaves out the newline at the end:systemd-ask-password -n "NAME:" | systemd-creds encrypt --with-key="$WITH_KEY" --name=NAME - /etc/nodkey/credentials/NAME.cred
To check a credential without showing it, count its bytes:
systemd-creds decrypt --name=NAME /etc/nodkey/credentials/NAME.cred - | wc -cThe clients and the broker connect over mutual TLS. Each side pins the key of the other side; there is no certificate authority (DESIGN.md section 10). Make the key of the broker:
nodkey keygen --out /run/nodkey-setup/broker-tls-keynodkey keygen prints the pin of the new key, such as
sha256:Q2b9.... Write it down: each client needs it as broker_pin.
The broker also logs it at each start. Now make the credential, and
remove the plain key:
systemd-creds encrypt --with-key="$WITH_KEY" --name=broker-tls-key \
/run/nodkey-setup/broker-tls-key /etc/nodkey/credentials/broker-tls-key.cred
rm /run/nodkey-setup/broker-tls-keyThe broker sends each request to you as a Telegram message from a bot of your own. Only your taps count.
-
Make the bot. Use Telegram on your phone, or on a trusted computer where no agent runs: never use BotFather on an agent machine. Open the chat with @BotFather and send
/newbot. Choose a name and a user name for the bot. BotFather answers with the bot token. Use this bot for this one broker only: Telegram lets only one program read the answers of a bot. -
Start the chat. Open the new bot in Telegram, and tap Start. A bot cannot write to you before you do this.
-
Save the token as a credential. On the broker host:
systemd-ask-password -n "Telegram bot token:" | systemd-creds encrypt --with-key="$WITH_KEY" --name=telegram-token - /etc/nodkey/credentials/telegram-token.cred
Never paste the token into a chat, an issue, a file in a repository, or a command line.
-
Find your user ID. The tap on Start sent the message
/startto the bot. On the broker host, before the first start of the broker:read -rs TOKEN # paste the token, then press Enter; it is not shown printf 'url = "https://api.telegram.org/bot%s/getUpdates"\n' "$TOKEN" | curl -sS --config - -H 'Content-Type: application/json' \ -d '{"allowed_updates":["message","my_chat_member"]}' unset TOKEN
Your user ID is the number in
"from":{"id":123456789.printfis part of the shell, and curl reads the address from its input, so the token is not in the list of processes.Never call
getUpdateswhile the broker runs. Telegram lets only one program read the updates of a bot. The broker would see a conflict, send an alert that the token may be stolen, and stop (exit code 76). -
A chat for notices (optional). A "never ask" rule (section 7) sends a silent notice for each use. By default, the notices go to your chat with the bot. To keep that chat for approvals only, make a Telegram group with only you in it, and add the bot. Nobody else may join this group: its members see the names of the secrets, the clients and the hosts. Then run the
getUpdatescommand again. The ID of the group is the negative number in"chat":{"id":-.... It starts with-100for a supergroup. It goes intonotice_chat_id. -
Keep the bot out of other groups (optional). If the bot is in no group, send
/setjoingroupsto BotFather and choose Disable. Then nobody can add the bot to a group.
For each value, the broker runs the program of the store: bws or bw
(DESIGN.md section 16). The program gets
a token or the master password from the broker, so it must be the real
program, and nobody else may change it.
- Take them only from the official releases:
bwsfrom thebws-v...releases of bitwarden/sdk-sm, and the standalonebwfrom thecli-v...releases of bitwarden/clients. The broker also acceptsbwfrom npm (@bitwarden/cli), but see below. Check each download. - Install them owned by root, with mode
0755, in a directory that only root can write to, such as/usr/local/bin. The broker checks this at start: the program and every directory on the way to it must be owned by root (or bynodkey), and group and others must not be able to write to them. - A
bwfrom npm is a Node.js script. The broker runsbwwithPATH=/usr/local/bin:/usr/bin:/bin, and the script starts thenodethat it finds there. Thatnodemust be owned by root, below directories that only root can write to, also after its links. The broker checks this at start. Anodefrom nvm in a home directory is not found there. - There must be no directory named
bw-datanext tobw, next to the file thatbwlinks to, or next to thatnode. With it, bw keeps its login and its copy of the vault there, not in the home directory of the store, so the broker refuses to start.
An example with bws 2.1.0 on x86-64 (take the newest release, and
aarch64 instead of x86_64 on 64-bit ARM). The checksum file of the
release lists the SHA-256 of each file:
cd /run/nodkey-setup
curl -fLO https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.1.0/bws-x86_64-unknown-linux-gnu-2.1.0.zip
curl -fLO https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.1.0/bws-sha256-checksums-2.1.0.txt
sha256sum -c --ignore-missing bws-sha256-checksums-2.1.0.txt
unzip bws-x86_64-unknown-linux-gnu-2.1.0.zip bws
install -o root -g root -m 0755 bws /usr/local/bin/bws
rm bws bws-x86_64-unknown-linux-gnu-2.1.0.zip bws-sha256-checksums-2.1.0.txtsha256sum must print OK for the zip file. An example with the
standalone bw 2026.9.1 (bw-linux-arm64-... on 64-bit ARM). Its release
has no checksum file, so compare the SHA-256 with the sha256: value
that GitHub shows next to the file on the release page:
cd /run/nodkey-setup
curl -fLO https://github.com/bitwarden/clients/releases/download/cli-v2026.9.1/bw-linux-2026.9.1.zip
sha256sum bw-linux-2026.9.1.zip
unzip bw-linux-2026.9.1.zip bw
install -o root -g root -m 0755 bw /usr/local/bin/bw
rm bw bw-linux-2026.9.1.zipWith gh, this prints the value from the release page:
gh release view cli-v2026.9.1 --repo bitwarden/clients --json assets \
--jq '.assets[] | select(.name == "bw-linux-2026.9.1.zip") | .digest'Prefer the standalone bw. npm install --global runs the install
scripts of every package in the tree as root, on the most trusted
machine that you have. If you still want the bw from npm, install the
node of your distribution (in /usr/bin), and an exact version of the
package, as root:
npm install --global @bitwarden/cli@2026.9.1
ls -l /usr/local/bin/bw "$(readlink -f /usr/local/bin/bw)" "$(command -v node)"All of them must be owned by root. A bad version of @bitwarden/cli was
on npm for about 90 minutes on 2026-04-22 (THREAT-MODEL.md T20). So
choose the version, and read the release notes before an upgrade.
Use a Bitwarden account that is never logged in on an agent machine: no web app in a browser, no browser extension, no desktop app and no CLI there. Every owner and admin of the organization can read every secret in Secrets Manager, and so can each member that you give access to the project (THREAT-MODEL.md T32). If your main Bitwarden account is logged in on an agent machine, make the organization for Secrets Manager with a separate account, and do not add your main account to it.
Bitwarden offers Secrets Manager for Free, Teams and Enterprise organizations. Its Free plan is for up to 2 users, 3 projects and 3 machine accounts. With a Families plan, Bitwarden says to make a new Free organization for Secrets Manager (Secrets Manager plans, seen 2026-10-16).
In the web app of Secrets Manager, with that account, on a trusted computer (section 1):
- Make a project for the secrets that nodkey may hand out. Put these secrets into it.
- Make a machine account. Give it read access to this project only.
- Make an access token for the machine account. It is shown only once.
- Copy the ID of each secret, a UUID. It goes into
idin the config. nodkey hands out the value of the secret.
On the broker host, make the credential:
systemd-ask-password -n "bws access token:" |
systemd-creds encrypt --with-key="$WITH_KEY" --name=bws-token - /etc/nodkey/credentials/bws-token.credbws logs in with the token at each run, so there is no login step. Its
state is in the home directory of the store,
/var/lib/nodkey/stores/NAME, where NAME is the name of the
[store.NAME] section: main in the example.
Bitwarden's EU cloud or your own server. Set the server once for
each bws store, as the broker runs bws: as the user nodkey, with
only HOME in the environment, in the home directory of the store. Do
this while the broker is stopped. umask 077 makes the new files
private, as UMask=0077 does for the broker. Without it, the files get
the umask of your shell, often 022. For the store main:
(
umask 077
install -d -o nodkey -g nodkey -m 0700 /var/lib/nodkey /var/lib/nodkey/stores /var/lib/nodkey/stores/main
sudo -u nodkey env -i -C /var/lib/nodkey/stores/main HOME=/var/lib/nodkey/stores/main \
/usr/local/bin/bws config server-base https://vault.bitwarden.eu
)The parentheses keep umask 077 inside this one block, so later files,
such as the unit, get their normal mode.
bws answers Profile updated successfully!.
A second machine account. A job with a "never ask" rule
(DESIGN.md section 12)
should get its secret through a machine account of its own. Put the
secret into a project of its own, and give the new machine account
access to that project only. Then give it a store section of its own:
in the example config, [store.backup-job] with the credential
bws-token-backup. Now even a mistake in the config cannot hand out
another secret through that store. Its token can read only that one
project.
The Password Manager CLI cannot unlock the vault without the master
password, so the broker must keep the password. Anyone who breaks into
the broker host gets the whole vault
(DESIGN.md section 11,
THREAT-MODEL.md T24). So use bw only with a separate Bitwarden
account that holds nothing but the items that nodkey may hand out.
Never log in to this account on an agent machine: no web app in a browser, no browser extension, no desktop app and no CLI there. Whoever can use the account can read every item in it, without nodkey (THREAT-MODEL.md T32). Share its items with no other account, and give no account emergency access to it: such an account could read them too.
-
Make the account, on a trusted computer (section 1). Give it a long random master password, for example from
openssl rand -base64 24. -
Add the items. Each secret is a login item, and nodkey hands out its password. nodkey refuses an item of another type, and an item in the trash or in the archive. Note the ID of each item, a UUID. One way: on a trusted computer where you use bw with this account,
bw get item "NAME OF THE ITEM" | jq -r .idprints only the ID. -
Check the KDF settings. The KDF settings are in the security settings of the account in the web vault. An unlock computes the key of the vault from the master password with them, and the broker gives each run of bw 30 seconds. Start with the default settings, and check the unlock after the first start (step 7). If it takes too long, such as on a small ARM board, lower them. With a long random master password, lower settings are still safe.
-
Make the credential of the master password:
systemd-ask-password -n "bw master password:" | systemd-creds encrypt --with-key="$WITH_KEY" --name=bw-master-password - /etc/nodkey/credentials/bw-master-password.cred
bw reads only the first line of this file, so the password must be one line.
-
Log in once, with exactly the environment of the broker. The broker runs bw as the user
nodkey, with onlyHOMEand a fixedPATHin the environment, in the home directory of the store. Run the login and the server setup in the same way, or bw keeps the login in another place (DESIGN.md section 16). Do this while the broker is stopped, on the console of the broker host or from a trusted computer.umask 077keeps the new files private, as for bws. For the storevault:umask 077 # new files private; run `umask 022` before you copy the unit (section 8) install -d -o nodkey -g nodkey -m 0700 /var/lib/nodkey /var/lib/nodkey/stores /var/lib/nodkey/stores/vault # Only for Bitwarden's EU cloud or your own server: sudo -u nodkey env -i -C /var/lib/nodkey/stores/vault HOME=/var/lib/nodkey/stores/vault \ PATH=/usr/local/bin:/usr/bin:/bin /usr/local/bin/bw config server https://vault.bitwarden.eu sudo -u nodkey env -i -C /var/lib/nodkey/stores/vault HOME=/var/lib/nodkey/stores/vault \ PATH=/usr/local/bin:/usr/bin:/bin /usr/local/bin/bw login --apikey sudo -u nodkey env -i -C /var/lib/nodkey/stores/vault HOME=/var/lib/nodkey/stores/vault \ PATH=/usr/local/bin:/usr/bin:/bin /usr/local/bin/bw status
login --apikeyasks for the personal API key of the account,client_idandclient_secret. You find it in the security settings of the account in the web vault. bw shows theclient_secreton the screen while you paste it.cleardoes not remove it from the scrollback of tmux or screen, or from a log of the terminal. So log in on the console of the broker host, or clear the scrollback too, for example withtmux clear-history. Thenbw statusmust show the separate account inuserEmail, and"status":"locked". Do not unlock: the broker does it. -
Never run
bw lock,bw unlockorbw logoutby hand in the home directory of a store. Each unlock makes a new session key, and ends the key of the broker. A lock locks the vault for the broker too. Then the next request is denied, and the broker unlocks again. After a logout, every request for that store is denied until you log in again as in step 5. -
Check the unlock after the first start (section 10). The journal must show
a store startedwithstore=vault. If it says that a store could not start, and the error hasbw unlockandtook too long, the unlock took more than 30 seconds: lower the KDF settings (step 3).
Skip this section if you use nodkey only for secrets. The broker still
needs a known_hosts file then. Make an empty one:
install -o root -g root -m 0644 /dev/null /etc/nodkey/known_hostsMake a new key on the broker host for each purpose. Do not use a key that was ever on an agent machine: the agent may have a copy (THREAT-MODEL.md section 5, item 8). The broker refuses a key with a passphrase; the encrypted credential protects the key instead. It takes Ed25519, ECDSA (P-256, P-384 and P-521) and RSA keys with at least 2048 bits.
ssh-keygen -t ed25519 -N '' -C nodkey-deploy -f /run/nodkey-setup/ssh-deploy
systemd-creds encrypt --with-key="$WITH_KEY" --name=ssh-deploy \
/run/nodkey-setup/ssh-deploy /etc/nodkey/credentials/ssh-deploy.cred
install -o root -g root -m 0644 /run/nodkey-setup/ssh-deploy.pub /etc/nodkey/ssh-deploy.pub
rm /run/nodkey-setup/ssh-deploy /run/nodkey-setup/ssh-deploy.pubAdd the public key, /etc/nodkey/ssh-deploy.pub, to
~deploy/.ssh/authorized_keys on each target host. Manage
authorized_keys with your configuration management, so that a key
that the agent adds there is removed again (THREAT-MODEL.md section 5,
item 2).
The broker finds the name of a host in its own known_hosts, never on
the agent machine. The name is what the message shows, so the file has
stricter rules than the known_hosts of OpenSSH
(DESIGN.md section 14):
- one plain host name on each line: no list of names, no wildcards, no
hashed names, no
[host]:port, and no@cert-authorityor@revoked; - plain host keys only, no certificates;
- at most one key of each key type for a name. A name may be on several lines, one for each key type;
- each host key only once. Two hosts with the same host key, such as cloned machines, are refused. Give each clone new host keys.
The name is the name in the messages, in the hosts lists of the
config, and in nodkey ssh-grant --host. It does not need to be the DNS
name.
Get the host keys in a safe way. Never copy them from the known_hosts
of an agent machine: the agent can change that file.
-
On each target host, list the fingerprints of its host keys. Use the console of the host, or an SSH session that you already trust:
for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done
-
On the broker host, fetch the host keys, and give each line the plain name.
-pgives the port, if it is not 22:ssh-keyscan -t ed25519,ecdsa,rsa web-1.example.com 2>/dev/null | awk '{print "web-1", $2, $3}' >> /run/nodkey-setup/known_hosts ssh-keyscan -t ed25519,ecdsa,rsa -p 2222 web-2.example.com 2>/dev/null | awk '{print "web-2", $2, $3}' >> /run/nodkey-setup/known_hosts
-
Show the fingerprints of the fetched keys, and compare them with the list from step 1. Each one must be the same. If one differs, stop: something between the broker host and that host may change the keys.
ssh-keygen -lf /run/nodkey-setup/known_hosts
-
Install the file:
install -o root -g root -m 0644 /run/nodkey-setup/known_hosts /etc/nodkey/known_hosts rm /run/nodkey-setup/known_hosts
Keep all host keys of a host in the file: ssh may use any of them. When a host gets a new host key, check its fingerprint in the same way, change the file, and restart the broker.
The config file is /etc/nodkey/broker.toml. A complete example, the
same as in
DESIGN.md section 14:
listen = "192.0.2.10:7743" # an address on your private network only
tls_key = "${CREDENTIALS_DIRECTORY}/broker-tls-key"
audit_log = "/var/lib/nodkey/audit.jsonl"
known_hosts = "/etc/nodkey/known_hosts"
[telegram]
token_file = "${CREDENTIALS_DIRECTORY}/telegram-token"
user_id = 123456789 # the approver
notice_chat_id = 123456789 # optional; default: the approver's chat
timeout = "120s" # secrets
ssh_timeout = "90s" # SSH signatures
[updates]
check = true # optional; false: never ask GitHub for a newer release
[client.laptop]
pin = "sha256:Yf3c...base64..." # printed by `nodkey keygen`
[store.main] # any name; `type` picks the program
type = "bws"
program = "/usr/local/bin/bws"
token_file = "${CREDENTIALS_DIRECTORY}/bws-token"
[store.backup-job] # a second machine account, one project
type = "bws"
program = "/usr/local/bin/bws"
token_file = "${CREDENTIALS_DIRECTORY}/bws-token-backup"
[store.vault]
type = "bw"
program = "/usr/local/bin/bw"
password_file = "${CREDENTIALS_DIRECTORY}/bw-master-password"
[secret.github-token]
store = "main"
id = "00000000-0000-0000-0000-000000000000"
clients = ["laptop"]
ask = "window"
window = "15m"
[key.deploy]
file = "${CREDENTIALS_DIRECTORY}/ssh-deploy"
clients = ["laptop"]
ask = "window"
window = "15m"
hosts = ["web-1", "web-2"] # optional; empty means any known hostLeave out what you do not use, such as a store or a key.
The top. listen is one IP address and port of the broker host on
the private network. A host name, 0.0.0.0 and :: are refused: the
broker must not listen on every network. tls_key is the credential
from section 3. The home directories of
the stores are in stores, next to audit_log.
[telegram]. token_file and user_id come from
section 4. notice_chat_id is optional.
timeout is how long a request for secrets waits for your tap: 15
seconds to 10 minutes, 120 seconds by default. ssh_timeout is the same
for an SSH login: 15 to 110 seconds, 90 seconds by default. sshd drops a
login that takes longer than its LoginGraceTime (120 seconds by
default).
[updates]. check is optional, true by default. Then the broker
asks GitHub 10 minutes after its start, and then once a day, whether a
newer release of nodkey is out, and sends you a silent notice when one
is (section 13). For this, the broker host
needs outbound HTTPS to api.github.com. The check uses the proxy of
HTTPS_PROXY, as the calls to Telegram do
(section 8). GitHub sees the address of the
broker host, or of its proxy, and the version of nodkey
(THREAT-MODEL.md T34). If
the broker host cannot reach GitHub, or GitHub should not see this, set
check = false. Otherwise the journal gets a line about the failed
check every hour.
[client.NAME]. One section for each agent machine, with the pin of
its TLS key. Make that key on the agent machine first
(section 11). The name shows in
each message as machine:.
[store.NAME]. type is bws with a token_file, or bw with a
password_file. program is the absolute path of the program.
[secret.NAME]. The name is what the client asks for, and what the
message shows. store names a store section, and id is the UUID of
the item in that store. clients lists the clients that may ask for
it.
[key.NAME]. file is the credential with the SSH key. clients
lists the clients that may use it. hosts lists the names in the
broker's known_hosts that it may log in to; without hosts, it may
log in to every host there.
The rule of each secret and key, ask
(DESIGN.md section 8):
"always": ask for each request. The buttons are Approve once and Deny."window": ask, and also offer Approve N min, where N is thewindow: 1 minute to 8 hours. A tap on it allows the same client the same secret, or the same key, host and remote user, for N minutes."never": do not ask. This is an open door for that one secret or key (DESIGN.md section 12). It needs exactly one client, and a key needshosts. It can havehours,max_per_dayandnotify.
Two more examples. A secret in the bw store, and a nightly job on a
machine of its own (backup-1) that gets its secret without a tap:
[secret.npm-token]
store = "vault"
id = "11111111-1111-1111-1111-111111111111" # a login item; nodkey hands out its password
clients = ["laptop"]
ask = "always"
[client.backup-1]
pin = "sha256:...base64..."
[secret.backup-token]
store = "backup-job"
id = "22222222-2222-2222-2222-222222222222"
clients = ["backup-1"]
ask = "never"
hours = "03:00-03:20" # local time of the broker; outside these hours, it asks
max_per_day = 1 # more uses in a day ask again
notify = true # the default: a silent notice for each useThe broker is strict, so that a typo never turns a rule off. An unknown
key, a key in upper case, or a key that is not below the header of its
own section stops the start. The error names the line or the key, never
a value. Paths are absolute. Only ${CREDENTIALS_DIRECTORY}/ and ~/ at
the start of a path are expanded.
Write the file as root, and make it owned by root, so that only root can change it:
chown root:root /etc/nodkey/broker.toml
chmod 0644 /etc/nodkey/broker.tomlThe file holds no secret, but it lists your secret names, hosts and item
IDs. To hide it from other users, use group nodkey and mode 0640
instead.
Copy the example unit
contrib/systemd/nodkey.service, of
the same version as your nodkey, to /etc/systemd/system/nodkey.service.
Never copy it through an agent machine: whoever can change the unit can
run a program as root on the broker host. After a build from the source,
the unit is in your copy of the repository. The release tarball does not
hold it. Take it from the tag of your version in the repository, such as
v0.1.0,
on the broker host or on the trusted machine. For 0.1.0, on the broker
host:
curl -fL -o /etc/systemd/system/nodkey.service https://raw.githubusercontent.com/code6x/nodkey/refs/tags/v0.1.0/contrib/systemd/nodkey.serviceThen change it to fit your setup:
- Keep one
LoadCredentialEncrypted=line for each credential that you made, and remove the others. systemd does not start the unit when one of these files is missing. - Change the paths if yours are different.
- If the broker must reach Telegram through a proxy, set it as the
comments in the unit say. Never use a proxy that inspects TLS: it sees
the bot token. The check for a newer release
(section 7,
[updates]) uses the same proxy to reachapi.github.com.
Check the unit. No output means no problem:
systemd-analyze verify /etc/systemd/system/nodkey.servicesystemd older than 253 warns that it does not know MemoryZSwapMax=.
You can ignore that: MemorySwapMax=0 alone is enough.
What the settings do:
| setting | what it does |
|---|---|
Wants= and After=network-online.target |
Start the broker after the network is up. |
Type=exec |
systemd sees a failure when the program cannot start. |
User=nodkey, Group=nodkey |
Run as the user of the broker, not as root. |
ExecStart= |
The program and the config file. |
LoadCredentialEncrypted=NAME:FILE |
Decrypt one credential at each start. The broker reads it as ${CREDENTIALS_DIRECTORY}/NAME. |
StateDirectory=nodkey nodkey/stores, StateDirectoryMode=0700 |
Make /var/lib/nodkey (the audit log) and /var/lib/nodkey/stores (the home directories of the stores), owned by nodkey, with mode 0700. The broker refuses a store directory with another mode or owner. |
UMask=0077 |
New files of the broker and of the store programs are private. |
Restart=on-failure, RestartSec=5s |
Start the broker again 5 seconds after a crash or an error. |
RestartPreventExitStatus=64 76 77 |
Do not start it again after wrong usage (64), a polling conflict at Telegram (76) or a bot token that Telegram refuses (77). Only a person can fix these. |
TimeoutStopSec=90s, KillMode=control-group |
The broker stops in at most 80 seconds. After 90 seconds, systemd kills it and every process of the unit, also a store program that left its process group. |
LimitCORE=1 |
No core dumps. A crashed bws or bw starts no core handler that could copy its token into the journal (section 9). The broker sets this limit itself too. |
MemoryMax=1G |
A memory limit for the broker and its store programs. |
MemorySwapMax=0, MemoryZSwapMax=0 |
The memory of the broker, with keys and tokens, never goes to swap. |
LogRateLimitIntervalSec=0 |
journald never drops a line of the broker. The anchors of the audit log are in these lines (section 12). The broker limits its own lines. |
Environment=, EnvironmentFile= (as comments) |
An optional proxy for Telegram, and for the check for a newer release at GitHub. A proxy with a password goes into a file that only root can read. |
NoNewPrivileges=yes and the lines after it |
The sandbox: no new privileges or capabilities, a read-only system, no access to /home, a private /tmp, no devices, kernel settings, other namespaces or raw sockets, and only the system calls of a normal service. |
WantedBy=multi-user.target |
systemctl enable starts the broker at boot. |
What the unit leaves out on purpose:
MemoryDenyWriteExecute=yes: bw runs on Node.js, which needs memory that is writable and executable.PrivateUsers=yes: the credentials and the config, owned by root, would look owned bynobody, and the broker would refuse them.PrivateNetwork=,IPAddressDeny=and the like: the broker must reach Telegram, and GitHub for the check for a newer release, andbwsandbwmust reach Bitwarden. Do not add them.
After systemctl daemon-reload, systemd-analyze security nodkey shows
how much the sandbox limits the broker.
Anyone who can use your Telegram account can approve (THREAT-MODEL.md T7).
- Never log in to the approving Telegram account on a machine where an
agent runs: no Telegram Desktop, and no Telegram Web in a browser
there. The agent could tap Approve itself, or copy the session and
use it from anywhere. The same holds for any other way to control the
phone from that machine, such as
adbor KDE Connect. - In Telegram, open Settings → Devices, and end every session that you do not need. Better: use an account that is logged in only on the phone.
- Turn on two-step verification (Settings → Privacy and Security), and a passcode for the app.
Anyone who can use a Bitwarden account that can read the secrets can
read them in Bitwarden's own apps, with no tap and no entry in the audit
log (THREAT-MODEL.md T32).
For bws, these are at least the owners and admins of the organization,
and the members with access to the project. For bw, it is at least the
separate account. T32 has the full lists.
- Never log in to such an account on a machine where an agent runs:
no web app in a browser, no browser extension, no desktop app and no
CLI there. The agent could read the secrets itself, or record the
master password when you type it. The same holds for a phone that the
agent machine can control, such as through
adbor KDE Connect. - Do not save the master password, the API key or the key of the two-step login of these accounts in a password manager that is open on an agent machine, such as your main Bitwarden account. With them, the agent could log in to the account from anywhere.
- If your main Bitwarden account is logged in on an agent machine, it must not be able to read the secrets. Make the organization for Secrets Manager with a separate account, and do not add your main account to it (section 5).
- After each change to the organization, check who is an owner or an admin, and who is on the People tab of the project, its secrets and its machine accounts.
- Turn on two-step login for these accounts, with an authenticator app or a security key (Bitwarden's guide). A code by email does not help if you read that email on an agent machine.
The memory of the broker holds the keys, the tokens and, for a moment, the secret values. Swap and hibernation write memory to the disk, where a copy of the disk can give them.
-
Check for swap with
swapon --show. Use no swap, or encrypted swap, such as a swap device in/etc/crypttabwith a new random key at each boot. zram keeps its swap in memory, which is fine. The unit also keeps the memory of the broker out of swap (MemorySwapMax=0). -
Turn off hibernation:
systemctl mask hibernate.target hybrid-sleep.target suspend-then-hibernate.target
A core dump of a crashed bws or bw would hold its environment, with
the token or the session key. The unit sets LimitCORE=1, and the
broker sets the same limit for itself and its store programs. With a
limit of exactly 1, the kernel writes no core file and starts no core
handler, but only when kernel.core_pattern is a pipe (|) or a file
name. The newer socket form (@) passes the dump to a program whatever
the limit
(DESIGN.md section 16).
sysctl kernel.core_patternkernel.core_pattern = |/usr/lib/systemd/systemd-coredump ...(the default of systemd),|/usr/share/apport/apport ...orcore: good.- A value that starts with
@: change it. Find the file that sets it withgrep -rs core_pattern /etc/sysctl.conf /etc/sysctl.d /usr/lib/sysctl.d /run/sysctl.d. Remove that line, or override it with a file such as/etc/sysctl.d/90-nodkey-core-pattern.confthat sets a pipe or the plainkernel.core_pattern = core. Then runsysctl --system, and check again.
Only the agent machines should reach the broker port. Any other machine
can at least make the broker do TLS handshakes
(THREAT-MODEL.md T28). An
example with nftables, with the agent machines 192.0.2.21 and
192.0.2.22. It only adds rules for port 7743, and leaves your other
rules as they are. Save it as /etc/nodkey/firewall.nft:
# The first two lines remove the old version of this table, if there is one.
table inet nodkey
delete table inet nodkey
table inet nodkey {
set agents {
type ipv4_addr
elements = { 192.0.2.21, 192.0.2.22 }
}
chain input {
type filter hook input priority filter; policy accept;
tcp dport 7743 ip saddr @agents accept
tcp dport 7743 drop
}
}
Load it with nft -f /etc/nodkey/firewall.nft. To load it at boot,
for example with the nftables service of Debian and Ubuntu, add the
line include "/etc/nodkey/firewall.nft" at the end of
/etc/nftables.conf. With ufw, allow the port for each agent machine
only, for example
ufw allow proto tcp from 192.0.2.21 to any port 7743.
The broker writes a hash of each audit entry to its log, which systemd keeps in the journal. These anchors find changes to the audit log (section 12), but only while the journal keeps them.
- Keep the journal on the disk: the directory
/var/log/journalmust exist, or setStorage=persistentin/etc/systemd/journald.conf. - Keep it long enough. journald removes old entries when it uses more
than
SystemMaxUse=: by default 10 % of the file system, at most 4 GiB. - Better: send the journal to another machine, for example with
systemd-journal-upload. Then not even root on the broker host can change the copies later.
Write the config file first (section 7), with at least one client (section 11). Then:
systemctl daemon-reload
systemctl enable --now nodkey
journalctl -u nodkey -fA good start looks like this. Here, the time= at the start of each
line, and some lines, are left out:
level=INFO msg="nodkey broker: starting" version=0.1.0 config=/etc/nodkey/broker.toml
level=INFO msg="nodkey broker: the config and the files it names passed their checks"
level=INFO msg="nodkey broker: core dumps are off: the soft and the hard limit of core dumps are 1, and the process is not dumpable"
level=INFO msg="nodkey broker: the TLS key is loaded" pin=sha256:Q2b9...
level=INFO msg="nodkey broker: an SSH key is loaded and can sign" key=deploy fingerprint=SHA256:3SMw...
level=INFO msg="nodkey broker: the audit log is open" path=/var/lib/nodkey/audit.jsonl
level=INFO msg="nodkey broker: a store started" store=main
level=INFO msg="nodkey broker: the listen address is open; nothing reads from it yet" address=192.0.2.10:7743
audit anchor 1:8318bd865cb1923faea0c3a1837f636ad1f8781898606701949120d612c85da0
level=INFO msg="nodkey broker: the approver runs: it polls Telegram for the answers"
level=INFO msg="nodkey broker: listening; the broker is ready" address=192.0.2.10:7743
The lines audit anchor SEQ:HASH are the anchors of the audit log
(section 12). When something is wrong, the
broker logs why (section 14).
A restart of the broker ends all grants, and denies the requests that
wait for a tap. The config is read only at the start, so restart the
broker after each change of the config or of known_hosts:
systemctl restart nodkeyOn the agent machine, as the user who runs the agent, make the TLS key of the client:
install -d -m 0700 ~/.config/nodkey
nodkey keygen --out ~/.config/nodkey/client.keynodkey keygen prints the pin of the new key. On the broker host, add a
section for the client to /etc/nodkey/broker.toml, add its name to the
clients lists of the secrets and keys that it may use, and restart the
broker:
[client.laptop]
pin = "sha256:Yf3c...base64..."The agent can copy this key. With it, someone can only ask, like the agent (THREAT-MODEL.md T11). They get what this client gets: what needs a tap still needs your tap, but a grant or a "never ask" rule of this client works for them too. To block a stolen key, remove its pin from the broker config, and restart the broker. Each machine needs its own key: the broker refuses two clients with the same pin.
Write the client config, ~/.config/nodkey/client.toml, with the
address of the broker and the pin of the broker's key
(section 3):
broker = "192.0.2.10:7743"
broker_pin = "sha256:Q2b9...base64..."
tls_key = "~/.config/nodkey/client.key"The pin is the only trust of the client in the broker, so other users must not be able to change the file. The client refuses it then:
chmod 0600 ~/.config/nodkey/client.tomlnodkey run --secret GH_TOKEN=github-token --reason "test the setup" -- \
sh -c 'echo "the command got ${#GH_TOKEN} characters"'The client prints nodkey: waiting for approval (request 7qd2). On the
phone, a message comes from your bot with the same short code. Check the
lines with ✅: they come from the broker. The lines below
"claimed by the agent" come from the agent machine. Tap Approve once.
The command prints how many characters the secret has, never the value.
nodkey never prints a value. The value goes only into the environment of the one command. For real work, the agent runs, for example:
nodkey run --secret GH_TOKEN=github-token --reason "publish the release the user asked for" \
-- gh release create v1.2.0Tell the agent about it, for example in its instructions for a project:
"For a command that needs a secret, use
nodkey run --secret VAR=NAME --reason TEXT -- COMMAND." Some variable
names are refused, because programs print or run their values
(DESIGN.md section 5).
nodkey run exits with the exit code of the command. When nodkey
itself stops, its last line on stderr is
nodkey: <reason> (exit <code>): 77 when the request was denied or
expired, 69 when the broker cannot be reached, 64 for wrong usage
(DESIGN.md section 15).
nodkey ssh-agent is an SSH agent without keys. For each login, it asks
the broker for a signature. Start it in a terminal of its own. It
prints the short code of each request there:
nodkey ssh-agentOr run it as a user service. Save this as
~/.config/systemd/user/nodkey-ssh-agent.service:
[Unit]
Description=nodkey ssh-agent: SSH logins with the keys of the broker
[Service]
ExecStart=/usr/local/bin/nodkey ssh-agent
[Install]
WantedBy=default.targetThen run systemctl --user enable --now nodkey-ssh-agent. The short
codes are in journalctl --user -u nodkey-ssh-agent -f.
In the shell of the agent, for example in ~/.profile:
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/nodkey/agent.sock"Check it:
ssh-add -l # the keys that this client may use, with their names
ssh deploy@web-1 true # a message comes for the loginssh, git and Ansible work through it with no changes. Some things to
know:
-
ssh must send
session-bind@openssh.com: OpenSSH 8.9 or newer. Paramiko and Go's own SSH client do not, so the broker refuses them. For Ansible, use its defaultsshconnection. -
The broker refuses host certificates. If a host offers one, ssh may use it. Then allow only plain host keys for that host in
~/.ssh/config:Host web-1 web-2 HostKeyAlgorithms ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256 -
The message shows
NODKEY_REASONfrom the environment ofsshas the reason. -
An approved login is one SSH session. That session can stay open, and ssh can share it with later commands (
ControlMaster,ControlPersist), without a new tap.
One Ansible run logs in to each host, so it needs one tap for each host.
nodkey ssh-grant asks once for a list of hosts. The key needs
ask = "window":
nodkey ssh-grant --key deploy --user deploy --host web-1 --host web-2 \
--for 15m --reason "apply the nginx fix"
ansible-playbook site.ymlThe message lists the key, the remote user, each host and the end of
the grants. After Approve, logins as deploy to these hosts need no
tap until that time. Other hosts and users still ask. A Deny for
the key ends its grants at once.
The broker writes each request and each decision to the audit log,
/var/lib/nodkey/audit.jsonl. Each entry holds the hash of the entry
before it, and the broker writes the hash of each entry to the journal:
audit anchor SEQ:HASH
(DESIGN.md section 9). Check the log with
every anchor since the log began, as root:
journalctl -u nodkey -o cat | nodkey audit verify /var/lib/nodkey/audit.jsonl --anchors -A good log gives:
ok: 1234 entries (torn lines: 0)
anchors: 1234 checked, all match; the newest is entry 1234
last entry: 1234:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
It exits with 0 when the log passed, and with 65 when it found a problem. Then it prints the line number, never the text of the line. A changed entry shows as an anchor that does not match. A log that someone cut shows as an anchor whose entry is missing.
-
Entries after the newest anchor are not proven.
-
If you moved the log away, the broker starts a new log at entry 1. Check the new log only with the anchors since then, such as
journalctl -u nodkey -o cat --since "2026-10-20 10:00", and keep the old file. -
jqshows the entries in short, for example the last ten:tail -n 10 /var/lib/nodkey/audit.jsonl | jq -c '{seq, event, client, secrets, key, hosts, answer, cause, code} | with_entries(select(.value != null))'
The broker tells you when a newer release is out, unless [updates]
says check = false (section 7). It asks GitHub 10
minutes after its start, and then once a day. For each newer release, it
writes a line in its journal, and sends you one silent notice in each
start:
ℹ️ nodkey: A newer release is out: nodkey 0.3.0. The broker runs nodkey 0.2.0. To update the broker, run as root on the broker host: nodkey update. Then restart it: systemctl restart nodkey. The restart ends all grants. Update the agent machines too: sudo nodkey update. Release notes: https://github.com/code6x/nodkey/releases/tag/v0.3.0
If the check or the notice fails, the journal says why, and the broker
tries again after 1 hour. On a terminal, nodkey version says it too, on
each machine. A build from the source never asks.
-
Read the release notes.
-
On the broker host, as root, set
VERSIONto the new version, such as0.2.0, and install it:VERSION=0.2.0 nodkey update --version "$VERSION"It runs the installer that is built into the installed nodkey, and checks the new release in the same way (section 2). It also takes an older version. nodkey 0.1.0 has no
nodkey update: update it with the installer of section 2, with--version "$VERSION". Or get the new version by hand, check it, and install it withinstall -o root -g root -m 0755 nodkey /usr/local/bin/nodkey. -
Get the example unit of the same version from its tag, as in section 8, but save it as
nodkey.service, not over your unit. After a build from the source, use the unit in your copy of the repository instead. Compare it with your unit, and take over the changes:curl -fL -o nodkey.service "https://raw.githubusercontent.com/code6x/nodkey/refs/tags/v$VERSION/contrib/systemd/nodkey.service" diff -u /etc/systemd/system/nodkey.service nodkey.service -
Restart the broker:
systemctl daemon-reload systemctl restart nodkey journalctl -u nodkey -n 30
-
On each agent machine, in a root shell as in section 2, install the same version, such as with
nodkey update --version 0.2.0. Then restartnodkey ssh-agentthere.
A restart ends all grants. Rarely, right after a restart, Telegram
reports a polling conflict, and the broker stops with exit code 76: a
poll of the old broker was still on its way
(DESIGN.md section 7). If no other
program uses the token, start the broker again with
systemctl start nodkey.
Until every machine runs the new version, nodkey tells you. Each client sends its version to the broker, and the broker sends its own back. nodkey 0.1 sends no version.
-
On the agent machine,
nodkey run,nodkey ssh-grantandnodkey ssh-agentprint a warning on stderr. It says which side is older, and what to do there:nodkey: warning: the broker runs nodkey 0.2.0, which is older than nodkey 0.3.0 on this machine; to update the broker, run "nodkey update" as root on the broker hostWith a broker of nodkey 0.1, the warning says that the broker runs an older nodkey that does not send its version, and points to this section: nodkey 0.1 has no
nodkey update. -
The broker writes a line in its journal, once in each start for each client that runs another version, or that sends none. It also sends you one silent notice for such a client:
ℹ️ nodkey: The client laptop says that it runs nodkey 0.2.0, which is older than nodkey 0.3.0 on the broker. To update it, run there: sudo nodkey updateFor a client of nodkey 0.1, the notice points to this section too. If the notice cannot go out, such as while Telegram cannot be reached after a power cut, the journal says so, and the next request of that client a minute later or more tries again. Rarely, the same notice comes more than once.
The steps above make both go away. A machine that still runs another
version works as before, unless the two versions do not understand each
other's requests: then the request fails, for example with
the answer of the broker is not valid. The version of a client is only
what the client says: the broker allows and denies nothing because of
it. A build from the source, such as one with the version dev or a
commit, gets no warning and no notice.
Install them in the same way as the first time (section 5): from the official release, checked, owned by root. Then check that the next request for a secret works. The login of bw stays in the home directory of the store.
Make a new key and its credential as in
section 3, and restart the broker. Then
put the new pin into broker_pin on each client, and restart
nodkey ssh-agent there. Until then, the clients refuse the broker.
To give a client a new key, make it with nodkey keygen in a new file,
put its pin into the [client.NAME] section, restart the broker, and
change tls_key in the client config. Remove the old key file. Restart
nodkey ssh-agent too: it reads the config and the key only when it
starts.
To block a client, for example after a strange request, remove its
[client.NAME] section, and its name from every clients list, and
restart the broker. Its key no longer works.
Make a new token with /revoke in BotFather. If BotFather says the bot
is invalid, send its user name with the @, for example
@my_nodkey_bot, or use /mybots, choose the bot, then API Token,
then Revoke current token. Then make the credential again as in
section 4, and restart the broker. Do this
at once when the token may have leaked. When Telegram no longer takes
the old token, the broker stops with exit code 77, and systemd does not
start it again until you do.
For bws, make a new access token for the machine account, make the credential again, restart the broker, and then revoke the old token in Secrets Manager.
For bw, change the master password of the separate account, make the credential again, and restart the broker. If bw is logged out after the change, log in again as in section 5, step 5.
Make a new key and its credential as in
section 6, with a new name, such as
ssh-deploy-2. Add its public key to the target hosts. Change file
of the [key.NAME] section and the LoadCredentialEncrypted= line of
the unit. Then run systemctl daemon-reload, so that systemd reads the
changed unit, and restart the broker. At the end, remove the old public
key from the target hosts.
The broker logs each problem in the journal: journalctl -u nodkey. An
error names the file, the section or the key, never a secret value.
| what you see | what it means, and what to do |
|---|---|
| The broker stopped with exit code 76, and an alert says that another program reads the updates of the bot. | Another program polls with the bot token, or a webhook is set. The token may be stolen. Make a new token (section 13), then start the broker. Right after a restart, see section 13. |
| The broker stopped with exit code 77. | Telegram does not take the bot token, such as after /revoke. Make the credential with the new token, then start the broker. |
The installer or nodkey update ends with nothing was installed (exit 1). |
The last line says which step failed. After a network error, try again later. After a failed SHA-256, attestation or version check, do not install that release in another way: someone may have changed it. Report it (SECURITY.md). |
The installer says that group or others can write to a directory or a file, or that a path is owned by user. |
As root, the installer runs programs from these places, so only root may be able to change them, and each directory above them. Fix the owner or the mode, such as with chmod go-w /usr/local/bin, or choose another place with --bin-dir, --gh or TMPDIR. A Debian system that was set up long ago may still have /usr/local/bin writable by the group staff. |
systemd does not start the broker: systemctl status nodkey shows status=243/CREDENTIALS. |
systemd cannot decrypt a credential, so the broker does not run, and nothing is handed out. Check that each file of a LoadCredentialEncrypted= line is there, and was made with that name. After a firmware update or a change of the Secure Boot settings, the TPM2 chip may refuse credentials that are bound to PCR 7 (section 3). If you did not update the firmware or change Secure Boot, find out why first: someone may have changed how the machine starts. Then make each credential again, as in section 13: a new TLS key (and its pin on each client), the bot token, a new bws token, the bw master password, and new SSH keys. |
| The journal says that the bot may not send to this chat. | You did not tap Start in the chat with the bot, or you blocked it. |
The client prints nodkey: warning: ... with the versions of this machine and the broker. |
They run different versions of nodkey. Update the older side, as the line says (section 13). Requests can fail until then, for example with the answer of the broker is not valid. |
| A silent notice says that a client runs another version of nodkey, or sent no version. | Update nodkey on the older side, as the notice says (section 13). The broker sends one such notice for each client in each start, rarely more than one, and the journal has a line about it. |
a store could not start |
The broker runs without that store, denies requests for its secrets, and tries again with each request. The error names the store and the step. For bw: check the login (section 5). |
| The broker refuses a file: the owner, the mode, or a directory that group or others can write to. | The broker checks every file that it reads, and every directory on the way (DESIGN.md section 14). Fix the owner or the mode that the error names. |
cannot listen on ... |
The listen address is not on the broker host yet, such as before the VPN is up. systemd tries again every 5 seconds. |
The client says the broker's TLS key is not the pinned key (exit 69). |
broker_pin in the client config is not the pin of the broker's key. The broker logs its pin at each start. |
The client says that the broker refused the TLS connection (exit 69). |
The broker does not know the key of this client. The broker logs refused a client key that no [client.*] section pins, with the pin. Put the pin into a [client.NAME] section, and restart the broker. |
The client says cannot connect to the broker (exit 69). |
Check the address in the client config, the firewall, and that the broker runs. |
The client says the broker answered 503 Service Unavailable (exit 69). |
The broker could not finish the request: Telegram, a store or the audit log failed, or the broker was starting or stopping. Its journal says why. |
The client says denied or denied by a limit of the broker (exit 77) at once, without a message. |
A check or a limit denied the request: a secret or key that this client may not use, or the pause of 60 seconds after a Deny. The journal of the broker says which. |
ssh asks for a password, or says agent refused operation. |
nodkey ssh-agent prints why. Check SSH_AUTH_SOCK, that the host is in the broker's known_hosts, and that ssh does not use a host certificate (section 11). |