Skip to main content

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:

CredentialScopeLifetime
agentone enrolled machine; reaches the Yard relay30-day session, rotating
userthe 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:

ControlWhere
Refresh tokens stored hashed (SHA-256) server-sideIdentityService.rotate
Single-use rotation — every refresh mints a new token, marks the old usedAtIdentityService.rotate
Origin binding — a BROWSER token is rejected on the CLI transport, and vice versaIdentityService.rotate
15-minute access token, 30-day sessionJwtService.ACCESS_TOKEN_TTL_SECONDS, SESSION_LIFETIME_DAYS
HttpOnly + path=/auth/v1 refresh cookie for the consoleAuthRoutes.refreshTokenCookie
File 0600, fails closed on loose permissions, atomic write, redacted toStringCredentialStore

Threat model​

Decomposing the threat is what makes this decision tractable — the answers differ per row.

#Threat
T1Credential swept into a backup, cloud-synced home dir, or support bundleleakage
T2Credential copied off the machine and used elsewhereexfiltration
T3Credential stolen, and the theft goes unnoticed for its full 30-day lifepersistence
T4Another user on the same machine reads itlocal, cross-user
T5An attacker running as the user reads or uses itlocal, 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.

ProductStorageResult
gcloud (Google Cloud SDK)~/.config/gcloud/credentials.db — plaintext SQLiteGoogle 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 — gh silently 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.

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 controltrusted-app ACL listaccess group + entitlement
/usr/bin/security can reach ityesno
Requiresnothingsigned 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:

EndpointAdd
/agent/v1/device-code/requestdevicePublicKey
/agent/v1/device-code/exchangesignature, timestamp
/agent/v1/refreshsignature, 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​

OptionProsConsVerdict
Layered (chosen)closes T1–T3; T2 via hardware; honest about T5five workstreams; three need Apple enrollmentadopted
Status quo — 0600 file onlyzero work; matches gcloud, aws, kubectlT1–T3 all openrejected — the industry moved
Keychain via /usr/bin/security shell-outtrivial; what gh shipsACL trusts a tool anything can invoke — no better than the file for T2/T5rejected, and the reason is filed upstream
GraalVM native-image for a real code-signature ACLgenuine process identitylarge reflection effort (kotlinx.serialization); still defeated by invoking our binary; superseded by access groupsrejected on security grounds
SecAccessControl with .userPresence (Touch ID per read)actually stops T5a background daemon cannot prompt; would fire constantlyrejected for the daemon; viable for future user-facing actions
Move the whole product API into the native helpertoken never leaveshelper grows without bound; three OS implementations of the productrejected — 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 refreshcorrect structure: freshness window, server-recomputable, binds refresh to a devicethe 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 vulnerablesuperseded 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 shippinguniformLinux Secret Service is inconsistent; Windows needs AclFileAttributeViewdeferred — 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 CredentialStore instead 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_jkt on the authorization request
  • RFC 8628 — device authorization grant
  • gcloud authentication
  • zalando/go-keyring#110, cli/cli#7123, cli/cli#10108