ADR-030: Agent credential custody — layered defence, not a storage silver bullet
Context
The Aucert agent is a long-lived daemon on a customer's own machine. It holds two
credential pairs, both in ~/.aucert/credentials.json at mode 0600:
| Credential | Scope | Lifetime |
|---|---|---|
agent | one enrolled machine; reaches the Yard relay | 30-day session, rotating |
user | the human's product-API access (aucert run, aucert status) | 30-day session, rotating |
ADR-028 chose that file deliberately: it is the same mechanism on macOS, Linux and
Windows, which is the whole point of a Kotlin/JVM client. CredentialStore's KDoc names
the tradeoff outright — "weaker at rest than the macOS Keychain… per-OS keystore
integration is a named follow-up."
This ADR is that follow-up. It was triggered by a direct question: is plaintext acceptable for a product whose credential reaches a customer's devices?
What already protects these credentials
Worth stating, because the answer below builds on it rather than replacing it:
| Control | Where |
|---|---|
| Refresh tokens stored hashed (SHA-256) server-side | IdentityService.rotate |
Single-use rotation — every refresh mints a new token, marks the old usedAt | IdentityService.rotate |
Origin binding — a BROWSER token is rejected on the CLI transport, and vice versa | IdentityService.rotate |
| 15-minute access token, 30-day session | JwtService.ACCESS_TOKEN_TTL_SECONDS, SESSION_LIFETIME_DAYS |
HttpOnly + path=/auth/v1 refresh cookie for the console | AuthRoutes.refreshTokenCookie |
File 0600, fails closed on loose permissions, atomic write, redacted toString | CredentialStore |
Threat model
Decomposing the threat is what makes this decision tractable — the answers differ per row.
| # | Threat | |
|---|---|---|
| T1 | Credential swept into a backup, cloud-synced home dir, or support bundle | leakage |
| T2 | Credential copied off the machine and used elsewhere | exfiltration |
| T3 | Credential stolen, and the theft goes unnoticed for its full 30-day life | persistence |
| T4 | Another user on the same machine reads it | local, cross-user |
| T5 | An attacker running as the user reads or uses it | local, same-user |
T4 is already closed by 0600. T1, T2 and T3 are open. T5 is not solvable — see below.
The finding that shapes everything: a signature proves whose runtime, never whose code
The intuitive fix is "use the macOS Keychain." A Keychain item in the file-based keychain is protected by an ACL listing trusted binaries, matched on code signature.
Our binary is a JVM. Contents/Resources/aucert is a bash script; the OS process is
agent/runtime/bin/java, and a JVM runs whatever classpath it is handed:
ACL trusts: our signed java
Attacker runs: <our signed java> -cp /tmp/evil.jar Evil
Keychain: granted — the binary IS ours
This is not a JVM quirk. The same defeat applies to python, node, or any
general-purpose interpreter. It also applies to a native helper that shells out to a
system tool.
Evidence from comparable products
Both data points were checked rather than assumed, and they disagree with each other — which is itself the useful signal.
| Product | Storage | Result |
|---|---|---|
| gcloud (Google Cloud SDK) | ~/.config/gcloud/credentials.db — plaintext SQLite | Google documents the risk: "Any user with access to your file system can use the stored access credentials created by gcloud auth login." Their mitigation is credential lifecycle (short-lived credentials, secret managers), not storage hardening. |
GitHub CLI (gh) | system keyring, default since v2.26 (Apr 2023) | On macOS it uses zalando/go-keyring, which shells out to /usr/bin/security. The ACL therefore trusts a general-purpose Apple tool that anything can invoke. |
gh's approach has open issues on exactly this:
- zalando/go-keyring#110 — "[MacOS] Insecure keychain usage": using
/usr/bin/security"makes it as secure as storing data on the file system with global read permissions." - cli/cli#7123 — open request to switch to native bindings.
- cli/cli#10108 —
ghsilently falls back to plaintext when the keyring is unavailable.
Conclusion drawn from this: moving to a keyring is right (the industry moved), but the naive implementation buys only T1. Getting T2 requires something else entirely.
Decision
Treat credential custody as five independent layers, not one storage choice. Adopt all five. Order them by what blocks them, so the unblocked, cross-platform work lands first.
Layer 1 — Refresh-token reuse detection (all platforms, unblocked)
Single-use rotation already exists. What is missing is acting on the alarm it raises.
Once tokens are single-use, a thief and the real user are in a race, and exactly one of them loses. Either way somebody presents a consumed token — a signal that only ever means two parties hold the same credential.
Today IdentityService.rotate sees tokenRecord.usedAt != null, logs it, and returns a
generic "invalid". If the attacker won the race, the user is quietly logged out, logs back
in, and the attacker keeps a 30-day session nobody revoked.
Decision: on a replayed refresh token, revoke the whole session
(sessionRepository.revoke + refreshTokenRepository.revokeBySession — both already
exist). Cost: the user signs in again. Benefit: T3 moves from undetected to self-healing.
This is the standard refresh-token-rotation recommendation (RFC 6819 §5.2.2.3).
Two things that must land with it, or it is a logout generator
Written after reading the clients. A replayed token is not automatically theft — our own clients can produce one on an entirely ordinary page load, and shipping the naive version would sign customers out for no reason.
1. The console must refresh single-flight. AuthContext.refresh() has no in-flight
guard, and authed-fetch calls it on every 401. A page whose three widgets all 401 at
once fires three concurrent refreshes against one cookie: one wins, the other two present
a consumed token. The CLI already does this correctly — EnrollmentManager holds a mutex
per credential and re-reads inside the lock, which "turns a lost race into a no-op instead
of a spent token." The console needs the same shape.
2. The server needs a reuse grace window. Single-flight in JavaScript cannot fix this on its own, because two browser tabs share one cookie jar and both hit the 15-minute renewal timer at about the same moment. No client-side lock spans tabs.
So a replay within REUSE_GRACE_SECONDS (order of 30s) is treated as a benign race: a
plain 401, no revocation. The loser retries, and by then the winner's Set-Cookie
has already rotated the shared jar, so the retry succeeds. A replay outside the window is
the theft signal, and revokes.
This grace window is what makes rotation-plus-reuse-detection deployable at all; Auth0 and Okta both expose the same knob under the name "reuse interval". Without it, the feature that is supposed to contain credential theft becomes the most common cause of logouts.
Layer 2 — Console refresh cookie SameSite=None → Lax (unblocked)
AuthRoutes.refreshTokenCookie sets SameSite=None in production, justified as
"console.aucert.ai and api.aucert.ai are different hosts, so the cookie must be sent
cross-site."
That reasoning is wrong: SameSite is evaluated on the registrable domain, and both
hosts are aucert.ai. They are same-site. Lax is sent on these requests already.
Impact today is small — path=/auth/v1 limits the surface and CORS stops an attacker
reading the response — but with Layer 1 in place it stops being cosmetic: evil.com could
POST to /auth/v1/refresh, consume the user's token, and the user's next refresh would
look like theft and revoke their session. Layer 2 must land with or before Layer 1,
or we ship a remote logout button.
Layer 3 — Native credential helper + data-protection Keychain (macOS; gated on Apple enrollment)
A small native, signed executable owns the Keychain item. The JVM never links
Security.framework; it runs a subprocess with a verb and JSON on stdin/stdout — the shape
docker-credential-osxkeychain and git-credential-osxkeychain established.
Critically, use the data-protection keychain, not the file-based one:
File-based (login.keychain) | Data-protection keychain | |
|---|---|---|
| Access control | trusted-app ACL list | access group + entitlement |
/usr/bin/security can reach it | yes | no |
| Requires | nothing | signed binary with the entitlement |
With kSecUseDataProtectionKeychain: true and
kSecAttrAccessGroup: $(TeamIdentifierPrefix)ai.aucert.credentials, the boundary stops
being "which binary are you" and becomes "are you signed by our Team ID with this
entitlement." That is the thing gh has an open issue to obtain, and it also solves
app ↔helper sharing cleanly: both declare the same access group.
Closes T1. Does not close T5 — an attacker can still invoke our helper. Neither Docker's nor Git's helper authenticates its caller; none can.
Fallback is explicit and logged. An unsigned build (a Homebrew formula compiled from
source), Linux, or Windows falls back to the 0600 file — and says so loudly. gh's
silent fallback is a filed bug; we will not reproduce it.
Layer 4 — The refresh call moves inside the helper
The helper, not the JVM, performs /agent/v1/refresh. The long-lived refresh token never
leaves the helper's address space; the JVM only ever receives a 15-minute access token.
An attacker who invokes the helper therefore obtains 15 minutes, not 30 days, and must remain resident on the machine to maintain access — which is detectable.
This deliberately does not generalise. The helper owns exactly the operations that touch the long-lived secret: store, get, erase, refresh, sign. That set does not grow with the product surface, so the helper stays small permanently.
Layer 5 — Proof-of-possession bound to the Secure Enclave
At enrollment the helper generates a keypair inside the Secure Enclave. The private key is not extractable by any software, root, or disk image. The public key is registered with the backend; every refresh carries a timestamp and a signature over it.
Closes T2: credential material stolen off the machine is inert anywhere else.
This is RFC 9449 (DPoP) in shape. The public key is registered on the device-code
request, not the exchange, so that the human's approval covers a specific key rather than
"whoever holds this code" — mirroring DPoP's dpop_jkt on the authorization request. The
exchange then carries a signature proving possession, which makes a stolen deviceCode
useless on its own.
Three API additions are required:
| Endpoint | Add |
|---|---|
/agent/v1/device-code/request | devicePublicKey |
/agent/v1/device-code/exchange | signature, timestamp |
/agent/v1/refresh | signature, timestamp |
Resulting enrollment flow (macOS app)
The app already keeps OAuth inside its WebView, so login needs no external browser. The device-code machinery (SPEC-076 B1/F1) already exists; the app drives it against its own WebView instead of a terminal.
1 helper check Keychain — already enrolled? stop
2 helper generate (or reuse) Secure Enclave keypair
3 helper POST /device-code/request + devicePublicKey
← deviceCode (secret, never displayed) + userCode (XXXX-XXXX)
4 app WebView → /devices/authorize?user_code=XXXX-XXXX
not signed in? → login → redirect back
5 user clicks Approve
6 helper POST /device-code/exchange + signature(deviceCode ‖ timestamp)
← credential
7 server stores the public key against this agent
8 helper writes the credential to the Keychain — it never enters the JVM
9 daemon asks the helper for the credential
10 refresh every /agent/v1/refresh carries timestamp + signature
The helper — not the JVM and not the Swift app — performs the polling, because only it can sign with the Enclave key. That the credential never transits the JVM is a consequence of that constraint, not an extra requirement.
Re-enrollment reuses the existing Enclave key so agentHandle continuity holds, and a
customer's registered devices and saved deviceRefs keep resolving.
Alternatives considered
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Layered (chosen) | closes T1–T3; T2 via hardware; honest about T5 | five workstreams; three need Apple enrollment | adopted |
Status quo — 0600 file only | zero work; matches gcloud, aws, kubectl | T1–T3 all open | rejected — the industry moved |
Keychain via /usr/bin/security shell-out | trivial; what gh ships | ACL trusts a tool anything can invoke — no better than the file for T2/T5 | rejected, and the reason is filed upstream |
GraalVM native-image for a real code-signature ACL | genuine process identity | large reflection effort (kotlinx.serialization); still defeated by invoking our binary; superseded by access groups | rejected on security grounds |
SecAccessControl with .userPresence (Touch ID per read) | actually stops T5 | a background daemon cannot prompt; would fire constantly | rejected for the daemon; viable for future user-facing actions |
| Move the whole product API into the native helper | token never leaves | helper grows without bound; three OS implementations of the product | rejected — Layer 4 gets the benefit by moving only the two operations that touch the secret |
Symmetric device-ID hash — H(deviceId ‖ timestamp) sent with each refresh | correct structure: freshness window, server-recomputable, binds refresh to a device | the device ID is a second secret in the same store — anyone who steals one steals both; the server must hold it, so a DB breach forges any device; raw H(secret ‖ data) is length-extension vulnerable | superseded by Layer 5 — same design, asymmetric key. Server holds only public keys; the private half can live in hardware and be copied by nobody |
| Per-OS keystores everywhere before shipping | uniform | Linux Secret Service is inconsistent; Windows needs AclFileAttributeView | deferred — macOS first, file fallback elsewhere |
Consequences
What becomes easier
- Enrollment in the Mac app has no terminal step. The user signs in; the code exchange
is invisible. This is the end state SPEC-074 wants — customers do not run
aucert init. - A leaked credential stops being permanent. Layer 1 makes theft self-healing; Layer 4 shrinks the window from 30 days to 15 minutes.
- Stolen material is machine-bound (Layer 5), so an Aucert token in a credential dump is not a usable credential.
- The credential stops appearing in backups and support bundles (Layer 3).
- We can state our posture precisely to a customer's security review, with the T1–T5 decomposition and an honest "T5 is not solvable by anyone" rather than a vague claim.
What becomes harder
- Three implementations of
CredentialStoreinstead of one — macOS helper, file, and eventually Windows. The interface already exists, which is why this is a leaf change. - Signing becomes load-bearing. Layers 3–5 do not function unsigned, so a Homebrew build-from-source formula silently loses them (and must say so).
- Enrollment gains a cryptographic step that must be testable without an Enclave — CI and Linux need a software-key path.
- Three API contract changes, each an
api-spec/change with regenerated clients. - Revoking an agent now also means forgetting a public key; key rotation needs a story.
Risks
- Layer 1 without Layer 2 is a remote logout button. Sequencing is a correctness requirement, not a preference.
- Layer 1 without the grace window is a self-inflicted logout button. Two tabs, one cookie jar, one renewal timer. The window has to be tuned against real clock skew and real retry behaviour, and set too short it will look exactly like the attack it exists to detect. Whatever value ships should be a named constant with the reasoning attached, not a literal.
- Enclave key loss bricks enrollment. A wiped Keychain, a restored-from-backup Mac, or a Secure Enclave reset leaves a private key that no longer exists. Re-enrollment must be a clean, discoverable path, not an error state.
- Clock skew breaks timestamp validation. The freshness window must tolerate modest skew in both directions, and the failure must be legible ("your clock is wrong"), not a generic 401.
- T5 remains open, permanently. Any credential a daemon can use unattended is one a local attacker can cause to be used. This ADR does not claim otherwise; it shrinks what they get. Anyone reading this looking for a claim that the agent is safe on a compromised machine will not find one.
- Apple Developer enrollment (SPEC-074 M0) gates Layers 3–5. Layers 1 and 2 are deliberately independent of it so that the highest-value change is not blocked on paperwork.
References
- ADR-028 — client agent runtime is Kotlin/JVM (the constraint this works within)
- SPEC-074 — Aucert-for-Mac; § Security model, M0 Apple enrollment
- SPEC-076 — CLI + headless enrollment; B1 device-code flow, F1 approval page
- RFC 6819 §5.2.2.3 — refresh token replay detection
- RFC 9449 — DPoP;
dpop_jkton the authorization request - RFC 8628 — device authorization grant
- gcloud authentication
- zalando/go-keyring#110, cli/cli#7123, cli/cli#10108