- Status: provisional
- Date: 2026-09-18
- Items: B-274, B-275, B-276, B-277, B-278
- Supersedes: nothing
- Related: 0008 §4.2, 0015, 0031 §4.7, PATTERNS.md §1.9,
crates/weida/src/config.rs(Identity,Trust,ServerTls,ClientTls),crates/weida/src/tls.rs
1. The question
weida's identity is a key: the SHA-256 of the leaf's SubjectPublicKeyInfo is what a peer pins, what an address names and what binds a peer's connections together ([0008 §4.2]). The certificate around the key exists because TLS requires one. Today an Identity is a pair of PEM sources — bytes in memory or a file path — read once when a TLS configuration is built (tls::server_config, tls::client_config), and the only way to make one is Identity::generate behind the generate feature.
That is enough for a test and for a process that pins by address. It is not enough for a process that must keep the same key across restarts (which [0031 §4.7] now depends on: a redial that reaches a different key is refused), for one whose certificate is issued and rotated by an authority, or for one that must prove itself to a secret store before it may ask for either. The owner's position: the identity implementations belong in weida, the default stays ephemeral, and an application connects a local store or an OpenBao/Vault instance. And one flow in particular must be implementable — a controller hands a freshly started process its credentials in a way that makes theft of the hand-off detectable.
2. Evidence: the sources, and what each must match on the service side
Every way a process can come by a key and a certificate falls on two axes: where the key lives and who issues and rotates the certificate. The axes are independent, and the fact that decides most of the design is that weida's fingerprint depends on the key alone: rotating a certificate under an unchanged key is invisible to pinning and to the redial of [0031]; rotating the key is a new peer.
| Source | Key | Certificate | Fingerprint stable | Must match on the service side |
|---|---|---|---|---|
Ephemeral (today's generate) | memory, per start | self-signed | no | nothing; trust is a pin in the address |
| Files | PEM on disk, bootstrapped on first start | self-signed or issued | yes | directory 0700, key 0600; with anchor trust the SANs must name what peers dial, because the anchor path checks names |
| Files, reloaded | as Files | written by an external agent | yes, when the agent reuses the key | Vault Agent templates, cert-manager, certbot --reuse-key, a SPIRE agent writing SVIDs: all are "somebody writes files", so weida must pick a changed file up without re-binding |
OpenBao/Vault PKI sign | local; weida builds a CSR | signed by a role, short TTL, renewed | yes | role key_type/key_bits equal to weida's key (rcgen: ECDSA P-256), allowed_domains/allow_ip_sans for the SANs, server_flag/client_flag for the EKU rustls' verifiers require, use_csr_sans, ttl ≤ max_ttl, require_cn=false; a policy on pki/sign/<role> |
OpenBao/Vault PKI issue | generated by the service, leaves it | as sign | no: a new key per rotation | as sign; only meaningful under anchor trust, never under pinning |
| OpenBao/Vault KV v2 | a blob | a blob | yes | mount, path, version, a read policy; distribution and backup only, no rotation, and the private key lives in the store |
Anchor from PKI (pki/cert/ca) | — | — | — | the counterpart of sign/issue: clients trust the CA and reload it when it rotates; weida checks no revocation (no CRL, no OCSP), which a short TTL is the only answer to |
| SPIFFE Workload API | rotated by the agent | X.509-SVID | no | a trust bundle per trust domain, the identity a SPIFFE ID in a SAN URI; four fifths of it is Files-reloaded, the rest is later |
| ACME | local | via a challenge | yes with key reuse | the challenge solver is its own program; covered by Files-reloaded |
| HSM / TPM / PKCS#11 | hardware | any | yes | Identity is PEM-shaped; a key that cannot be exported needs a rustls SigningKey path; later |
Three sources cover almost all of it — ephemeral, files with reload, and OpenBao as a signer — because SPIFFE, ACME and cert-manager all end in a file somebody else writes.
The hand-off. A controller (systemd, root) starts a service that must authenticate to OpenBao before it can ask for anything. The controller's own long-lived token may only mint child tokens of a role that names the service. It mints one and asks OpenBao to wrap it: the child token goes into a cubbyhole and what the service receives is a wrapping token — valid for a few seconds, redeemable exactly once (sys/wrapping/unwrap), and good for nothing else. The service redeems it as its very first action and gets the real token: renewable, periodic, tied to the process. Redemption is the detector: an attacker with the service's uid can read the hand-off, but whoever redeems second fails, and "already used" is a defined error in the audit log rather than a timeout. Failure is an alarm — the process exits, the controller revokes the child token by its accessor and everything leased under it dies with it, and the same revocation runs at every process exit. What the flow proves is the hand-off; what it cannot protect is the running process's memory against the same uid, which is systemd hardening's job (ProtectProc, NoNewPrivileges, ptrace_scope). This is OpenBao's own response-wrapping mechanism, minted in ExecStartPre= and revoked in ExecStopPost=. Delivery, as found by B-277: the credentials directory (LoadCredential/SetCredential) is the natural place, and it is the place the controller's token rides — but it is mounted read-only for ExecStartPre= as for the service, so a pre-start cannot leave a freshly minted wrapping token there. The wrapping token travels through RuntimeDirectory= instead: 0700, the unit's uid, removed with the unit, unlinked by the service once redeemed. Same uid, same tmpfs semantics, one more line in the unit. And the controller keeps the child's accessor from the minting answer (wrapped_accessor), so a theft — where the service never gets far enough to report anything — still ends with the thief's token revoked.
sequenceDiagram
participant C as controller (systemd, root)
participant B as OpenBao
participant S as service (uid svc)
C->>B: token/create role=svc, meta{unit}, wrap_ttl=5s
B-->>C: wrapping token (single use, 5 s)
C->>S: start; wrapping token in $RUNTIME_DIRECTORY, accessor kept
S->>B: sys/wrapping/unwrap (first action)
alt nobody redeemed it first
B-->>S: service token (renewable, periodic)
loop lifetime
S->>B: token/renew-self; pki/sign/role (CSR)
end
else somebody did
B--xS: already used
Note over S: exit: hand-off stolen
end
S-->>C: exit
C->>B: token/revoke-accessor3. Options
| Option | Shape | Precedent | Named loss |
|---|---|---|---|
| A — identity stays the application's | weida takes an Identity; every application writes its own store, its own OpenBao client, its own rotation | today | every consumer rebuilds the same three sources, and none of them can rotate a certificate under a live binding, because that needs rustls' resolver, which is weida's |
| B — sources in weida, one crate | IdentitySource and the OpenBao client both in weida | — | an HTTP client and a JSON codec in the core crate for a store most processes never talk to; the workspace has neither today |
| C — sources in weida, the store in its own crate | the trait, the ephemeral and the file source in weida; weida-openbao beside it with the HTTP client | the way weida-runtime and the competitor libraries are cut (0013) | a second crate to version; the trait must be public and stable enough for an out-of-tree source |
4. The decision
Option C.
4.1 An identity is a source, not a value. IdentitySource yields the current Identity and signals change; TrustSource does the same for anchors. ServerTls and ClientTls accept a source where they take a value today, and a value is the trivial source that never changes. A binding installs a rustls certificate resolver that reads the source, so a rotated certificate is served to the next handshake without re-binding; a dialling endpoint reads the source when it builds a connection. The pool keys on the trust configuration and the identity source, not on the certificate of the moment, so a rotation does not fork connections.
4.2 Three sources in weida. Ephemeral — what generate does today, behind the same feature, the default. Files — a directory the source owns: bootstrap a self-signed identity there on first use with 0700/0600, load it afterwards, and reload when the files change (mtime polling at a configured interval; no inotify dependency), which is how every external agent plugs in. Static — an Identity value, for a caller that manages the material itself.
4.3 weida-openbao. One crate, one HTTP client, one JSON codec, both named in its manifest with the reason: the OpenBao API is JSON over HTTPS and the workspace has neither. It offers Auth::{Token, AppRole, Handoff}, where Handoff reads a wrapping token from a file or the credentials directory, unwraps it before anything else, and turns a failed unwrap into IdentityEvent::HandoffStolen plus an error the application exits on. A renewal task keeps the service token alive. On top of the client: PkiSign (a Files key, a CSR, pki/sign, renewal at a fraction of the TTL), PkiAnchor (a TrustSource from pki/cert/ca or the chain, reloaded), and Kv (an IdentitySource from a KV v2 secret). PkiIssue is named and not built: it rotates the key, which under pinning is a new peer every TTL.
4.4 Rotation is observable. IdentityEvent::{Loaded, Renewed, RenewalFailed, KeyChanged, HandoffStolen} on a bounded stream per source, in the shape of [0031 §4.8]'s PeerEvent. RenewalFailed is the one an operator needs: a certificate that will expire in an hour is a fact long before the handshake fails.
4.5 What the key change means. A source that reports KeyChanged has become a new peer for every pinning client: their redial refuses it as PeerChanged ([0031 §4.7]), which is correct and which the event makes deliberate rather than surprising. Anchor-trusting clients notice nothing.
4.6 Revocation stays unchecked, and the documents say so. No CRL, no OCSP, in the PKI source or anywhere else. The mitigation is the TTL, and the source exists to make a short TTL affordable.
5. What this does not decide
- Hardware keys. A
SigningKeythat never leaves a TPM needsIdentityto stop being PEM; the trait is written so a source can return one later, and nothing is built. - SPIFFE as a first-class source. Covered by
Filestoday; a Workload API client is a fourth crate when a deployment asks. - Authorization. 0015 stands: who may do what on a path is the acceptor's verdict on the proved key, and no token in the handshake changes that.
- The controller side of the hand-off. Minting the wrapped token is a
baoinvocation inExecStartPre=; B-277 ships the unit file and the script, not a Rust controller.
6. Consequences and follow-ups
- PATTERNS.md §1.9 gains a paragraph on where an identity comes from and what rotation does to the fingerprint.
- INVARIANTS.md gains the event-stream bound of §4.4 and the poll interval of
Files. - GUARANTEES.md §6 peer-identity row: unchanged in meaning, gains the sentence on unchecked revocation.
docs/libraries/getsweida-openbao.mdwhen B-275 lands, in the shape the other library documents have.
B-274 — Identity and trust as sources: ephemeral, static, files with reload, live rotation
kind: code | size: 90 | status: done 3e6828b | needs: [] acceptance: §4.1, §4.2, §4.4 in crates/weida: IdentitySource/TrustSource traits with a current value and a change signal; Static, Ephemeral (feature generate) and Files (bootstrap 0700/0600, mtime reload at an interval) sources; ServerTls::from_source and ClientTls::with_identity_source alongside the value constructors, which become the static source; a rustls resolver on the binding that serves the rotated certificate to the next handshake with no re-bind; IdentityEvent on a bounded stream. Tests: a binding whose files are replaced under it serves the new certificate to the next connection and the old connection is unaffected; a key replaced on disk yields KeyChanged and a pinning client's redial reports PeerChanged; the bootstrap writes owner-only files and a second start loads them with the same fingerprint.
B-275 — weida-openbao: the client and the three auths, hand-off included
kind: code | size: 90 | status: done 9e24256 | needs: [B-274] acceptance: §4.3 first half: crates/openbao/weida-openbao with reqwest (rustls, no default features) and serde_json, both justified in the manifest; Auth::Token, Auth::AppRole { role_id, secret_id } and Auth::Handoff { wrapping_token: Source } where the source is a path or $CREDENTIALS_DIRECTORY/<name>; unwrap before any other request; a renewal task on auth/token/renew-self at half the TTL; a failed unwrap is IdentityEvent::HandoffStolen and an error. Tests against a scripted HTTP server in the crate for every auth and for the stolen hand-off (unwrap answers 400 "wrapping token is not valid or does not exist"), and one #[ignore]d test against bao server -dev on the local binary that mints a wrapped token, unwraps it, and sees the second unwrap fail.
B-276 — PkiSign, PkiAnchor, Kv
kind: code | size: 60 | status: done 9e24256 | needs: [B-275] acceptance: §4.3 second half: PkiSign builds a CSR from a Files key (rcgen), calls pki/sign/<role> with use_csr_sans, installs the chain, and renews at a configured fraction of the TTL with Renewed/RenewalFailed; PkiAnchor is a TrustSource from pki/cert/ca (or ca_chain) reloaded at an interval; Kv reads chain and key from a KV v2 secret. The role settings of §2 are checked where they can be (key_type mismatch is an error naming the role), and stated in the crate's README where they cannot. Tests: scripted server for all three; the #[ignore]d bao -dev test enables a PKI mount, a role, signs weida's CSR, and a client trusting pki/cert/ca connects to a binding whose identity came from PkiSign, then the binding rotates and the client's next connection verifies against the new chain with the same fingerprint.
B-277 — The systemd hand-off, end to end
kind: code | size: 45 | status: done 9e24256 | needs: [B-275] acceptance: crates/openbao/weida-openbao/examples/handoff/: a unit file with ExecStartPre= minting the wrapped child token (bao token create -role … -wrap-ttl=5s) into SetCredential, ExecStopPost= revoking by accessor, ProtectProc, NoNewPrivileges, Restart=on-failure; a service binary that unwraps, signs its identity and binds; a README that states the policy the controller token needs and the role the service token has. Proved on this workstation by running the unit under a user manager against bao server -dev, once cleanly and once with the wrapping token consumed by a second bao unwrap first.
B-278 — Documents
kind: docs | size: 30 | status: done 9e24256 | needs: [B-274] acceptance: §6's four edits, plus docs/libraries/weida-openbao.md once B-275 exists.
7. Sources
weida documents: 0008 §4.2; 0015; 0031 §4.7, §4.8; PATTERNS.md §1.9; GUARANTEES.md §6.
weida code: crates/weida/src/config.rs (Pem, Identity, Trust, ClientTls, ServerTls), crates/weida/src/tls.rs (server_config, client_config, spki_fingerprint), crates/weida/src/pool.rs (the pool key).
OpenBao 2.6: sys/wrapping/wrap, sys/wrapping/unwrap, sys/wrapping/lookup, auth/token/create, auth/token/renew-self, auth/token/revoke-accessor, auth/approle/login, pki/sign/:role, pki/cert/ca, pki/ca_chain, KV v2 data/:path. systemd: systemd.exec(5) LoadCredential=/SetCredential=, $CREDENTIALS_DIRECTORY, RuntimeDirectory=, ExecStartPre=/ExecStopPost=, ProtectProc=, NoNewPrivileges=; Yama ptrace_scope. rustls: ResolvesServerCert, ResolvesClientCert, sign::CertifiedKey.