Node identity
What makes up a bcd node's identity on disk, how bcd join creates it with each attestor, how renewal replaces the key every day, what revocation does on the node, how bcd join --reset recovers, and how node keys live in the TPM.
A node's identity is a P-256 key and a short-lived certificate the control plane's JoinService issued for it. bcd
creates it once with bcd join, replaces the key and certificate at 60% of every certificate's lifetime, and uses
the key for every TLS handshake and every statement the node signs. This page describes the node's side: the files,
the join, renewal, revocation, recovery with bcd join --reset, and node keys held in the host's TPM.
The control plane's side is elsewhere: joining nodes covers tokens, the join transaction and certificates, attestation covers each attestor and the TPM proofs, and PKI covers the keys and pins behind it all.
What the identity is
Everything lives in the data directory (data_dir, default /var/lib/bcd):
| File | Mode | What |
|---|---|---|
identity.json | 0600 | The identity the verified JoinResponse and later renewals established (below) |
node.key | 0600 | The node key: a PKCS#8 PEM (PRIVATE KEY), or, for a key in the TPM, only its record, a PEM block of type BCD TPM NODE KEY whose bytes are the 32-byte unique the key is derived from |
node.key.next | 0600 | The key a renewal is certifying, until its certificate is stored |
journal.db | 0600 | The command journal; it belongs to one node id, and bcd node refuses another node's |
identity.json holds:
| Field | Meaning |
|---|---|
nodeId, siteId, trustDomain, role | Read from the leaf certificate's SPIFFE ID and its OU=bc-role:<role> attribute (node, site or ramdisk) |
chain | The certificate chain, leaf first, DER |
notAfter | The leaf's expiry |
trustVersion, trustRoots, trustBundle | The trust bundle the node holds: its version, command-signing roots and the whole serialized bundle (approver keys for the destructive-batch quorum). No roots means signed intent is not enforced |
signedTrustBundle | The same bundle as the JoinService key signed it, from a Welcome; bcd helper takes host units' roots from it |
serverTime | The server time high-water mark, mirrored from the journal at each Welcome, so a lost journal does not reset it |
The identity outlives a lost journal: a corrupt or wiped journal starts over with a new journal id, but the node keeps its key and certificate (see journal loss).
Loading it
Every agent mode loads the identity with LoadState at start:
- No
identity.jsonisthis node has not joined (run bcd join). - A
node.key.nextwhose public key the stored leaf certifies is promoted tonode.key(a renewal stopped after it stored the certificate); any othernode.key.nextis removed (a renewal stopped before). node.keymust be the leaf's key. If it is not:the node key does not match the node certificate; join again: bcd join --reset --yes, then bcd join. For a TPM key the message addsthe TPM derives another key from node.key (was the TPM cleared?), since a cleared owner hierarchy derives another key from the same record.
bcd doctor's identity check reports the same: a warning when the node has not joined, a failure for an identity
that does not load or an expired certificate, each ending with the way back.
Joining
bcd join creates the identity. It runs as root, takes the join credential from one source, and never from a
command-line argument (a positional argument is refused without being echoed).
| Credential | How | Attestor |
|---|---|---|
| A join token | BCD_JOIN_TOKEN (unset once read), else the first line of standard input (at most 4 KiB) | join-token |
| A token file | --token-file PATH: a regular file, mode 0600 or 0400, at most 4 KiB; deleted once the join succeeds or the machine had already joined (a read-only mount logs a warning instead) | join-token |
| A provisioned token | --node-id <node> with a token: the OS a deploy installed joins as that node, and the join fails unless the issued identity is that node | provisioned |
| The TPM | --attestor tpm: no token is read or sent (BCD_JOIN_TOKEN is dropped; --token-file is a usage error) | tpm |
| The kernel command line | bcd ramdisk only: the bc.token and the site agent's bc.assertion | discovery |
--role site joins a site agent with a site token; the token decides what is allowed. --server, the three pin flags
and --data-dir bootstrap a machine that has no config.toml from the control plane's well-known document (see
joining from the well-known document). bcd produces no
x509pop or cloud-iid evidence: those attestors exist in @better-compute/pki, but a bcd node whose certificate
expired offline re-joins with a token, the TPM or --reset instead.
What the join does
agent.Join (daemon/internal/agent/join.go):
- The key. For a
tpmjoin on a host whose node keys use the TPM, the key is made in the attestor's own TPM connection so the AK can certify it; otherwise a key in the host's TPM when there is one, else a PKCS#8 file key (see node keys in the TPM). Then a PKCS#10 CSR. - The seal. A token is sealed to the JoinService encryption key, which must match
join_encryption_key_pin(fromjoin-encryption.jwkbeside the config, or the well-known document with--serverwhen that file is absent). The seal is bound to the CSR's SHA-256. Join. Sent over TLS pinned to the serving CA (serving_ca_spki_pin), with the CSR, the hardware claims (product UUID and serials, permanent MACs, GPU UUIDs, hostname, boot id), the attestor, its evidence, the daemon version and the role. Retryable failures back off, up to 20 attempts; a gateway'sgrpc-retry-pushback-msis waited out exactly.- The answer.
pki.OpenJoinResponseverifies the signature underjoin_signing_key_pin, thatcsr_sha256is this request's, that the leaf certifies this key, the SPIFFE ID and the role. A deferral is answered with the same CSR: aJoinChallengefor this attestor (at most 3 rounds, refused once itsnot_afterpassed), or aJoinPendingapproval, waited out (at least one second) and, for an attestor with a challenge, followed by a fresh round. - Root's record. On a node with
[helper], whenbcd joinruns as root, the verifiedJoinResponseis written as signed to/var/lib/bcd-hostconfig/join-response.pb(0600) before the identity (a966cd4). A failed record fails the join and leaves no identity. bcd helper takes the node id and the command-signing roots for host units from it. - Persist. If the data directory held another node's identity, its
journal.dbis moved aside asjournal.db.<oldNode>-<unix time>. A stalenode.key.nextis removed, thennode.keyandidentity.jsonare written. - Hand over. Under
[helper], rootLchownsidentity.jsonandnode.keyto the[helper] user, so the unprivilegedbcd nodecan read them (b4fcc2e); a name swapped for a symlink changes only the link.
It prints joined as <node> (site <site>, role <role>); certificate valid until <time>. On a machine whose identity
loads, bcd join reads no credential, answers already joined as <node> (site <site>, role <role>) and exits 0, so a
join unit can run on every boot; a different --node-id is refused. Every refusal from the control plane is the same
ACCESS_DENIED ("Join refused"); the reason is only server-side (see refusals).
The pins
config.toml key | Protects |
|---|---|
serving_ca_spki_pin | The TLS connection to the gateway: never system roots |
join_encryption_key_pin | The key a token is sealed to |
join_signing_key_pin | The JoinResponse signature, trust bundles, and root's join record under the helper |
command_root_sha256 (optional) | Command-signing roots for host units under the helper (see signed specs) |
The install script writes them from the PKI bundle; a malformed pin stops bcd from starting.
Renewal
A node certificate lasts at most 24 hours (a ramdisk's at most 2). The agent renews at 60% of the leaf's lifetime
(renewFraction), with Renew on the gateway, which relays to JoinService. renewOnce:
- Chooses the next key. If the leaf names a TPM AK (
urn:bc:tpm-ak:, written after a certifiedtpmjoin; d94f1f3), bcd loads that AK alone, checks its TPM name is the one the certificate names, and makes the next key in that TPM. Otherwise the next key goes into the TPM when the host has one, so a file-key node moves into the TPM here, else it is a file. - Writes
node.key.nextfirst, then builds the request: a new CSR, the current chain, the client time and a proof of possession by the current key (purposerenew-pop). - Attaches the AK's proof for a certificate that names one:
key_attestationwith the AK's public area, the new key's public area, and the AK'sTPM2_Certifyof it oversha256(csr). - Checks the answer: the new leaf names the same node, site, trust domain and role, and certifies the new key.
- Stores in order:
node.key.nextagain (in case something removed it), thenidentity.json, then renamesnode.key.nextovernode.key. A crash at any point leaves a key and certificate that match. The new state keeps the trust version, roots, bundle and server time. The old key is unloaded 5 minutes later, once nothing can still be signing with it.
A failed renewal is logged as renewal failed with the attempt and expiresIn, and retried with backoff from 30
seconds to 10 minutes, or exactly when a busy gateway says (it allows 6 renewals per node and hour). There is no
downgrade for a certificate that names an AK: when the TPM is unavailable, or derives another AK, the renewal fails
before anything is sent, and keeps failing until the certificate expires.
| At renewal | Message |
|---|---|
| The TPM cannot be opened | renewal: the certificate's node key is in the TPM, which is unavailable: ... |
| Another AK | renewal: this TPM's attestation key is not the one the certificate names (another TPM, or its endorsement seed changed): the certificate cannot be renewed here; join again: bcd join --reset --yes, then bcd join |
| No next key in the TPM | renewal: the next node key in the TPM: ... |
| No certification | renewal: the TPM's attestation key certifying the next key: ... |
| An answer for another identity | renewal: the certificate is for <site>/<node> (<role>), not ... |
| An answer for another key | renewal: the certificate does not certify the new key |
The control plane's checks and refusal reasons are on renewal and renewing a TPM-held key. A certificate that expired cannot be renewed; see joining again.
Revocation
The control plane revokes a node's certificates with nodes.revokeIdentity, by deleting the node, or on its own for a
certificate that looks cloned (see revoking a node's
identity). A gateway with a revocation feed then sends
the node's live session a RevocationNotice and closes it.
On the node, the notice is logged as certificate revoked with its reason, the agent stops (ErrRevoked), and
bcd node exits 1 with agent: this node's certificate was revoked. It does not redial with that certificate, and
nothing on the node deletes or renames the identity. The revoked certificate cannot renew, re-join or open a session
again (the gateway answers UNAUTHENTICATED), and a later identity generation means older certificates stay dead. The
machine comes back only by joining again with fresh evidence: a new token, or the TPM, after bcd join --reset.
Joining again with --reset
An identity that loads but no longer works (expired, revoked, refused at renewal, naming an AK this TPM no longer has)
makes bcd join answer already joined forever. Since a1c1a46, --reset moves it aside first:
# as root: show what would move (moves nothing, exits 2)
bcd join --config /etc/bcd/config.toml --reset
# move it aside and join again, with a new token on standard input
bcd join --config /etc/bcd/config.toml --reset --yes < join-token.txt
# or with the TPM
bcd join --config /etc/bcd/config.toml --reset --yes --attestor tpm| Behaviour | Detail |
|---|---|
| Root only | Outside fakehw builds a non-root --reset fails with bcd join --reset: run it as root |
| What moves | identity.json, node.key, node.key.next and journal.db, whichever exist, each renamed <name>.reset-<UTC time> (for example node.key.reset-20260926T114200Z). Nothing is deleted |
Without --yes | Prints the files, that the following join makes a new key and joins again (a new node unless the attestor proves this machine and reclaims it), and exits 2 |
| Then | The usual join, with the usual credential; if it fails, the identity stays moved aside and a later bcd join joins |
Whether the machine comes back as the same node depends on the attestor. tpm with the same EK and provisioned
reclaim their node; a token join of a known machine needs a token with allowReclaim, or the old node deleted first.
The cases are on joining again with --reset and recovering a node
whose TPM changed. Under [helper], restart
bcd-helper.service and bcd.service afterwards; the join also rewrites root's join record, so the helper binds host
units to the new node.
Node keys in the TPM
bcd keeps the node key in the host's TPM when there is one (965c14d, 50e18f9, 0ba2a79). Whatever holds it, the key is
a Go crypto.Signer, and nothing on the wire changes: the same P1363 signatures and the same kid.
| Property | Value |
|---|---|
| Template | tpm.NodeKeyTemplate: an unrestricted P-256 signing key, fixedTPM, fixedParent, sensitiveDataOrigin, userWithAuth, noDA, sign, a primary of the owner hierarchy derived from a 32-byte unique |
| Storage | Nothing is persisted in the TPM. node.key keeps the unique, and the key is derived again at every load |
| Signatures | ECDSA with the digest's hash (SHA-256, -384 or -512) |
| Device | /dev/tpmrm0, the kernel's resource manager, opened once per process so the keys' handles stay valid together. A fakehw build uses its software TPM only with BCD_FAKEHW_TPM_KEYS=1 |
| When | Every new key: at bcd join and at every renewal. When the TPM cannot hold a key, bcd logs a warning and makes a file key instead, except at the renewal of a certificate that names an AK, which fails |
| What it protects | The key cannot be copied off the machine, but root on the node can derive and use it: non-exportable, not root-proof |
internal/tpm is bcd's own TPM 2.0 client (f61b32d): it builds the TPM's command format directly over the transport,
with no library, and sends only CreatePrimary, ReadPublic, NV_ReadPublic, NV_Read, StartAuthSession,
PolicySecret, ActivateCredential, Quote, PCR_Read, Sign, Certify and FlushContext. The same package
builds the tpm attestor's evidence (EK, AK, the EK certificate from NV index 0x01C00002) and its round-2 answer;
see bcd with its TPM.
Through bcd helper
The kernel's resource manager filters no command, so whoever can open /dev/tpmrm0 could change the hierarchies'
authorization, clear the TPM or trip its lockout. Since 8870b8d (security review 3) an unprivileged bcd node never
opens it:
| Process | Opens the TPM |
|---|---|
bcd join (root) | Itself, [helper] or not |
bcd node as root (no [helper]) | Itself |
bcd node under [helper] | Never: agent.TPM is helper.TPMKeys, the helper's tpm.* operations |
bcd helper (root) | Itself, for the node |
The node sends tpm.nodekey, tpm.sign, tpm.ak, tpm.certify and tpm.release, each naming a key by its unique,
with a 30-second timeout. It checks every answer: the derived key must have the node-key attributes, every signature
must verify against the key's public half (the helper's signature is not the node key's otherwise), and a
certification must come from the AK the helper announced (the helper certified with another AK). What the helper
checks is on bcd helper. The install script no longer puts bcd in the tss group, and
bcd doctor's tpm check fails while it is still a member.
Seeing where the key is
| Tool | Shows |
|---|---|
bcd status | node key in the TPM or node key a file on its identity line; nodeKey (tpm or file) in --json |
bcd doctor node key | ok in the TPM; ok for a file key on a host without a TPM; warn for a file key on a host with one ("the next renewal moves it into the TPM") |
| The control plane | nodeKey: 'tpm' in node.joined and the audit row only for a certified tpm join, and the urn:bc:tpm-ak: statement in its certificates; every other node is unattested, and the control plane has only bcd's word |
What the tests cover
| Test | Covers |
|---|---|
internal/agent join, state and renewal tests | Deferrals, challenges, verification refusals, the next-key promotion and discard, TestRenewCertifiedByTheAK against tpmsim (no TPM or another AK fails before any request) |
TestJoinReset, TestJoinHandsTheIdentityToTheNode (cmd/bcd) | The dry run, the move (journal included), no identity loading afterwards, and the hand-over to the helper's user (as root) |
internal/tpm TestTSContract | bcd's evidence and answer against the TypeScript tpmAttestor through fixtures, both directions |
internal/helper TestHelperTPMKeys | The tpm.* operations against tpmsim |
tests/e2e/bcd.test.ts | Real joins, sessions and renewal of fakehw bcd binaries against the real gateway |
tests/e2e/bcd-tpm.test.ts | A tokenless bcd join --attestor tpm with an EK certificate from a test manufacturer root, a rejoin as the same node, and a renewal certified by the AK, all on tpmsim |
Better Compute is created by Sean Filimon
Last updated
Instance runtimesexperimental
The runtimes bcd runs VPS and sandbox instances on, what each one does for disks, NICs, GPUs, volumes, snapshots, consoles and seeds, how placement and admission apply, and how they run under bcd helper.
Self-updateexperimental
The node side of daemon upgrades, from the signed release manifest and its checks to the A/B slots, the release floor, confirmation, rollback, the crash-loop unit, the helper's half and the bcd update command.