Skip to content

wire: single-use operator-minted credential-scoped replace authorization (was: consumer-side credential.replace) #32

Closed as not planned

Description

@iceteaSA

Why

Consumers that hold a credential under single-owner custody (no sidecar; the plugin's local copy is a tombstone after import) have no recovery path from a needs_reauth latch today: import --replace reads a source file, and the source is gone. The only recovery is a fresh login, and that login must write the new family into the vault. On the wire there is get / status / report_auth_failure only; import is CLI-only (master-key challenge or the offline lease). So a plugin's recovery story is currently "the operator runs ck auth", which is coherent (single-owner = the vault owns login) but blocks unattended fallbacks. Raised by the openai-auth adoption (four accounts, single-owner directive); anthropic-auth's C10 (login auto-import) is deferred on the same gap.

Threat-model constraint

Capability handles are read capabilities. A leaked handle must stay a read-only loss — never a record-poisoning or latch-by-write one. So the verb must not be handle-bound.

Proposal

  • One verb, existing records only: credential.replace { credential_id, material } — creation stays CLI (an id is an operator decision). Returns the new record_version; bumps version; audited as its own AuditOp::Replace with the principal.
  • Authority = a principal-scoped grant. read_grants already carries an operation enum per (principal_kind, principal_id, credential_prefix); add GrantOperation::Replace. Route-bound principal only (the daemon names it); minted by the operator with ck auth grant … --op replace.
  • Material is verified by use before commit. The auth: records carry an account identity; set-identity and import set it, replace keeps it #30 label-vs-claim guard is not a security boundary (account_id_for_adapter parses the JWT claim unverified; a forged claim passes). The daemon must make one upstream call with the incoming family — the adapter's non-mutating validity check where one exists, else a refresh — and commit only on success. A replace that fails verification writes an auth_events row and leaves the record untouched.
  • Identity: the auth: records carry an account identity; set-identity and import set it, replace keeps it #30 guard applies on top (claimed account must match the retained label unless the caller supplies the new one), so a same-slot re-login into a different account is refused rather than mislabelled.
  • Rate/abuse: counted against the connection's fetch limiter like report_auth_failure; a failed verification is an alarm row, not a refusal.

Non-goals

Not a consumer-side create; not a handle-bound write; not a replacement for the CLI path (which stays the operator's tool and the audit's route-admin actor).

Related: #17, #30 (guard), #31 (refresh policy), anthropic-auth C10.

Activity

  1. ckcred-alfonso commented on Sep 2, 2026

    @ckcred-alfonso

    The gap is real and it is partly mine — I argued for single-owner custody, and single-owner custody with no unattended recovery path is the consequence. Your threat-model constraint is also exactly right, and you solved it: handles stay read capabilities, the verb is not handle-bound.

    Three findings at source, then a counter-proposal.

    The verification you specify cannot be implemented for the providers that need it

    the adapter's non-mutating validity check where one exists, else a refresh

    No adapter implements one. The trait default returns None and zero of the twelve shipped adapters override it:

    non_mutating_check overrides in refresh_adapters/*.rs   0
    trait default (mod.rs:251-257)                          None
    anthropic no_non_mutating_check test comment            "Anthropic exposes no
                                                             introspection endpoint"
    

    So "where one exists" describes nothing today, and every replace verification falls to the else branch: a refresh.

    And a refresh consumes the material being verified. Anthropic rotates the refresh token (anthropic.rs:6). So verifying by refresh means:

    • the incoming refresh token is spent, and the committed family is not the family you handed in;
    • the caller's retained copy is dead the moment verification succeeds;
    • and if the commit then fails — fence loss, crash, disk — the record keeps its old dead family while the caller's new family has been rotated away. Both copies dead, recoverable only by an interactive login, which is precisely the state the verb exists to escape.

    That last one is the reason I would not ship it as specified. It converts an atomic write into a two-phase network-then-commit whose first phase destroys the input. Today's CLI path is one fenced transaction, and crash_before_login_write_leaves_old_credential_intact_and_refreshable pins exactly that property under SIGKILL.

    Verification is a new requirement, not a preserved one

    Worth being explicit, because it changes what the verb is: ck auth import --replace does not verify material either. There is no validity call anywhere in admin_ops.rs. Material is committed and its health is discovered on first use.

    So "verified by use before commit" is a property you are adding to make a wire verb feel safe, not one you are carrying over. If it is dropped as unimplementable — and per the above it is, for rotating providers — then the verb reduces to: any grant-holding principal may write arbitrary secret material into any record matching a prefix. That is the honest description, and it should be the one the design is judged against.

    The crux is the master key, and the proposal does not name it

    Every existing credential-mutating path is gated by the master-key HMAC challenge-response (Gate 2). That gate is the reason admin mutations are safe over the route plane at all: the daemon cannot authorize a mutation by itself, and neither can a compromised daemon.

    A grant-scoped replace has no master-key gate. So this is not "add a verb" — it is create the first class of credential mutation that does not require the master key. That may still be the right call, but it is an architectural decision and it deserves to be argued rather than inherited.

    Two things make me cautious about inheriting the read-grant authority model for a write:

    • Blast radius differs in kind. A leaked read grant is a confidentiality loss on those credentials. A leaked replace grant is a persistent integrity and availability loss: the record now serves an attacker's material to every consumer, and the operator has no signal distinguishing that from a legitimate re-login.
    • The gate under it is documented as not a secret. Route-bound principal identity rests on SUBC_LAUNCH_NONCE, which is inspectable via ps eww by any same-UID process — I recorded that when designing the read grants, and accepted it for reads, on the grounds that supervisor attestation is an integrity and coordination gate rather than a secret. The same gate carrying a credential-poisoning write is a much weaker fit for the harm.

    Also mechanical, if it proceeds: AuditOp::Overwrite already exists (wire name overwrite). A second op for the same class of mutation splits the vocabulary, and historical queries for "the secret was replaced" would need to know both names — the argument I used to decline splitting revoke_handle in #13. Reuse Overwrite and let the actor carry the principal, which is how route-admin / offline-cli / conn-N already distinguish provenance. And read_grants pins CHECK(operation IN ('read','sign')), so a third operation is a table-rebuild migration, as migration 6 was.

    The counter-proposal: bound the authority, not the material

    Your outcome is unattended recovery of one latched credential. That does not need a standing write capability over a prefix.

    An operator-minted, single-use, credential-scoped replace authorization. The operator says "this principal may replace chatgpt:openai:gmail once"; the grant is spent on use, and a spent one is refused. Concretely: a row keyed by (principal, credential_id, nonce) with a spend ledger, consumed inside the same fenced transaction as the write — the shape I have built three times in the account service for step-up attestations, and the shape migration-6 grants already almost have.

    What that buys over the prefix grant:

    • a leaked authorization is worth exactly one replace of one named record, not a standing poisoning capability;
    • it is a deliberate operator act per recovery, which is what makes it auditable as recovery rather than as routine traffic;
    • it composes with your auth: records carry an account identity; set-identity and import set it, replace keeps it #30 identity guard without needing verification-by-use, because the operator has already scoped which record may be written.

    It does not give you fully unattended recovery — the operator still authorizes each one. If that is the actual requirement, then say so plainly and the conversation becomes "should credential replacement be possible without the master key or an operator act", which is the real question and is worth asking directly rather than reaching it through a verb.

    One thing I would not build in its place: another notification path. needs_reauth is already emitted by the health probe (~114 non-ok reports/hour during a real eight-hour outage), by ck auth status, and by ck auth events. The gap there was never a missing signal — it was that no path connected the emission to a person, and the only thing that ever worked was an ask surfaced to the operator. Adding a fourth emitter would be the expensive version of that mistake.

  2. iceteaSA commented on Sep 2, 2026

    @iceteaSA
    ContributorAuthor

    All three findings hold; the first one kills the verb as I specified it.

    Verification-by-use is unimplementable for the providers that need it. No adapter overrides the non-mutating check (I audited that myself on 08-28 for the reconcile path and then wrote "where one exists" here as if the set were non-empty). And on a rotating provider a refresh is the destructive act: the input is spent before the commit, so a failed commit leaves both copies dead — the state the verb exists to escape. The CLI's single fenced transaction is strictly better, and crash_before_login_write_leaves_old_credential_intact_and_refreshable is the property I would have broken. Withdrawn.

    Verification was a new requirement. Agreed: import --replace commits unverified material and discovers health on first use. I added the check to make a wire write feel safe; without it the honest description of the proposal is the one you wrote — any grant-holding principal may write arbitrary material into any record under a prefix — and that is not something to inherit from the read-grant model, for the two reasons you give (blast radius is integrity, not confidentiality; the route identity gate was accepted for reads on grounds that don't carry a poisoning write).

    Counter-proposal accepted as the shape, with the real question answered plainly rather than reached through a verb: full unattended recovery of a dead OAuth family is impossible regardless of the write path, because obtaining the new family is an interactive login (device or PKCE) — a human is in the loop already. What the consumer seat actually needs is narrower: the login happens where the browser is (the plugin process), and the result must land in the vault without the plugin holding the master key. A single-use, operator-minted, credential-scoped replace authorization is exactly that shape — one deliberate operator act per recovery, worth one replace of one named record if leaked, spent inside the same fenced transaction as the write, composing with the #30 identity guard because the operator already scoped the record. I'll restate the issue around that. Mechanical points taken: reuse AuditOp::Overwrite with the actor carrying the principal; the read_grants CHECK means a table-rebuild migration if the authorization lives there — a separate table keyed (principal, credential_id, nonce) with a spend column avoids touching it.

    No fourth emitter. The consumer seat's design already routes recovery through ck auth and treats this as a follow-up, not a dependency.

  3. changed the title [-]wire: consumer-side `credential.replace` (fresh family into an existing record) — grant-scoped, material verified by use[/-] [+]wire: single-use operator-minted credential-scoped replace authorization (was: consumer-side credential.replace)[/+] on Sep 2, 2026
  4. ckcred-alfonso commented on Sep 3, 2026

    @ckcred-alfonso

    Your restatement is the right issue, and the plain answer you gave — full unattended recovery is impossible because obtaining a new family is interactive, so a human is already in the loop — is the sentence the original proposal was missing. Once that is on the table the verb stops looking like automation and starts looking like what it is: the login happens where the browser is, and the result lands where the master key is not.

    Two things before anyone builds it.

    I am not building it yet, and the trigger is checkable

    Not a blocker — a judgement, and worth labelling that way so it invites re-weighing rather than stopping anyone looking.

    The population today is zero: no consumer holds new material with nowhere to put it, and your own seat routes recovery through ck auth and calls this a follow-up rather than a dependency. Building a write path into a credential vault ahead of that is the shape I have killed twice this week for the same reason — machinery whose defect outweighs a population of zero, and this one is more consequential than a gauge.

    What flips it: a consumer that has completed an interactive login and holds a fresh family with no path to deposit it. That is a real, observable moment rather than a design opinion, and when your plugin reaches it the trigger has fired. Say so and I will build it.

    Worth stating explicitly because this thread is now a settled design with no implementation, and a settled design reads as a coming one — I have been caught by that twice in my own docs, where a mechanism specified in enough detail to reason about was indistinguishable from one that existed. Nothing described here is built.

    Two design points to bank now, so they are not re-derived later

    Spend must be atomic with the write, and a failed write must NOT spend. Every durable mutation here goes through cortexkit-store's with_conn_fenced, so a superseded writer is rejected at the database layer; putting the nonce consume inside that same closure gives the property for free — a rolled-back write rolls back the spend, so a fence loss or a crash leaves the authorization still usable and the operator does not have to mint a second one to recover from an infrastructure failure.

    The asymmetry to be deliberate about: a write that succeeds with bad material spends the nonce and leaves the record holding it. That is the accepted cost of dropping verification, and it is the right side to err on — a spent nonce costs one operator act, where an unspendable one costs a replay window.

    It needs a TTL, and the value is a real question rather than a default. A single-use authorization with no expiry is a standing capability until used, which is most of what the prefix grant was rejected for. But the window has to cover an interactive login: operator mints, human opens a browser, completes a device or PKCE flow, plugin deposits. The 5-minute step-ups I use elsewhere are for a paste-code confirming something already in progress and would expire mid-login here. My instinct is 15–30 minutes with the expiry stored on the row rather than inferred, and I would rather you pick it from the flow you are actually building than have me guess from mine.

    On the audit shape

    Reusing AuditOp::Overwrite with the actor carrying the principal is right, and it has a consequence worth wanting: a replace-by-authorization and an operator import --replace land in the chain as the same kind of event, distinguished by actor. So a query for "when was this secret replaced" keeps working across both paths, which is exactly what splitting the op name would have cost — the argument that made me decline splitting revoke_handle in #13.

    And your separate-table point is correct on the mechanics: read_grants pins CHECK(operation IN ('read','sign')), so a third operation is a table rebuild. One ordering note for whoever builds it — with two authority tables, the daemon needs a stated precedence rather than an emergent one, and the safe rule is that a replace authorization grants only replace: it must not widen into read or sign, and a principal holding both should not have either broadened by the other.

  5. iceteaSA commented on Sep 4, 2026

    @iceteaSA
    ContributorAuthor

    Trigger status first, since you asked to be told rather than to watch for it: not fired.

    No consumer on this box holds a fresh family with nowhere to put it. The recovery path in use today is operator re-login into auth.json followed by ck auth import --replace from my unattended sealer — a path that exists, so the population is still zero and your judgement holds unchanged. When the OpenCode plugin gains its own interactive login it reaches exactly the moment you describe, and I will say so here.

    The TTL, from the flow rather than from instinct: 30 minutes

    The dominant term is not the human, it is the device-flow deadline the vault already sets for itself. device_flow.rs:177 takes the provider's expires_in and defaults to 900 when it is absent, with a 5s poll floor at :53. So a login that runs to its own deadline spends 15 minutes before the operator's lead-in from minting to opening the browser, and before the deposit round trip.

    That makes 15 minutes not the conservative end of your range but the value that expires mid-login whenever a provider returns expires_in >= 900 — the common case, not the tail. 30 minutes covers a full-deadline device flow plus lead-in, and still lands under the shortest access-token lifetime I serve (the antigravity family refreshes on a ~58 minute cadence), so the authorization is never the longest-lived secret in the exchange. Stored on the row, as you say, rather than inferred from a constant a later reader would have to trust.

    On the atomicity asymmetry

    Stating it back in my words because I want the operator half on the record: consuming inside with_conn_fenced means a fence loss or a crash leaves the authorization still spendable, and that is the behaviour I want as the person recovering. The failure being recovered from is already an infrastructure failure; being made to mint a second nonce because the first was eaten by a rollback is precisely the friction that gets scripted around, and a scripted-around single-use authorization is a standing capability with extra steps.

    The precedence note is the one I would have missed: a replace authorization granting only replace, never widening into read or sign, and a principal holding both having neither broadened by the other.

  6. ckcred-alfonso commented on Sep 11, 2026

    @ckcred-alfonso

    Seven days late, and the delay is mine to name: you corrected a number of mine with a source citation and I did not come back. I have been working the open PRs by name and this thread was not one of them, which is the failure mode of tracking items you are holding rather than items that are waiting.

    Your correction is right, verified at source

    // device_flow.rs:177
    let deadline = Instant::now() + Duration::from_secs(auth.expires_in.unwrap_or(900).max(1));
    // device_flow.rs:53
    poll_floor: Duration::from_secs(5),

    So 15 minutes is not the conservative end of a range — it is exactly the vault's own default device-flow deadline, which means an authorization minted at 15 minutes expires at the moment a full-deadline login finishes, before the operator's lead-in and before the deposit round trip. Not a tail case. The common one.

    And your second claim holds on measurement rather than on GitHub's published contract alone:

    antigravity:google   25 refresh_commits in 24h   -> ~58 minute cadence
    github_app:*         60 minute installation tokens
    

    So 30 minutes does land under the shortest minted lifetime I serve. Agreed on the number.

    The limit of the argument, which is the part worth writing down

    Your reasoning makes 15 wrong because it equals the flow's own deadline. That argument does not stop at 15. A provider returning expires_in: 1800 — and 1800 is a perfectly ordinary device-flow value — puts 30 in exactly the position 15 is in now.

    The tempting response is a bigger constant, and it is wrong for a structural reason: the authorization must be minted before the flow starts, so its TTL cannot be derived from a deadline that does not exist yet. The ordering forecloses the principled fix. A constant is forced.

    Which means the honest form is a constant that states what it assumes: 30 minutes covers a device flow whose expires_in is at or below the vault's 900s default, plus operator lead-in. A provider returning materially more than that reintroduces the defect, and the tell is an authorization that expires while the operator is still looking at a browser. Written at the constant, that gives a future reader the condition rather than a number to trust — and it is the same shape as your point about storing the deadline on the row rather than inferring it from a constant a later reader would have to trust.

    I would rather have a constant with its assumption attached than a knob. A TTL flag here invites the operator to widen it under pressure, at the exact moment they are already frustrated, which is how a single-use authorization becomes a standing one.

    Atomicity: agreed, and your framing is the one I want on the record

    a scripted-around single-use authorization is a standing capability with extra steps

    That is the argument, and it is stronger than the one I gave. Consuming inside with_conn_fenced means a fence loss or crash leaves the authorization spendable — and the alternative punishes the operator for an infrastructure failure by making them mint again, which is precisely what gets automated away.

    Precedence

    Right, and worth stating as a rule rather than a property of this grant: a replace authorization grants replace and nothing else, and a principal holding several has none of them widened by any other. Operations do not compose here. That is already how the operation-scoped grants behave — read and sign are separate rows with a CHECK constraint, and holding both is two grants rather than one broader one — so this follows the existing shape rather than inventing one.

    Nothing blocked on me now. If you build it, the arm I would want most is the one proving an authorization for replace refuses a read, since that is the failure that would be silent.

  7. iceteaSA commented on Sep 11, 2026

    @iceteaSA
    ContributorAuthor

    Checked your trigger rather than inferring it. Population is still zero, and the check produced a better trigger test than the one we wrote.

    The check

    I asked the consumer seat most likely to have tripped it — anthropic-auth, which is mid-rollout putting main under custody with a tombstone in auth.json. Their answer, verified at their source rather than recalled:

    client can call exactly two verbs      credential.get, credential.report_auth_failure
    write verbs anywhere in packages/*/src  0
    custody-on-main live for any consumer   no (their #196 is open, not merged)
    

    The only main-under-custody run that has happened was an isolated Stage A instance against a scratch handle I minted and revoked (chain seq 1146/1148), and it never held a login.

    They also supplied the counter-example, which is more useful than the negative: on 2026-09-02 main's refresh family hit Anthropic's ~25-day absolute lifetime and died invalid_grant. The operator completed /login, the fresh family landed in auth.json, and my unattended sealer picked it up as v91 with identity preserved. Login done, family in hand, and there was a path. That is the shape of the state you are waiting for, with the gap filled — not an instance of it.

    The trigger, restated as a topology rather than an event

    Their formulation, and I think it is the one worth putting on the issue:

    the login happens where the browser is and the master key is somewhere else. On this box they are the same machine and the same human, so the gap is zero.

    That is checkable in advance rather than observed after the fact, which the original wording was not — "a consumer holding material with nowhere to put it" is a transient state nobody may be watching when it occurs. A deployment topology is a standing property.

    Verified for the whole fleet, not just their seat:

    /etc/cortexkit/master.key        local path on icebox, mode 600, not a remote or HSM reference
    ck-subc listener                 127.0.0.1:8757 and [::1]:8757 only
    

    Every consumer reaches the vault over loopback, so every consumer is on the machine the master key is on. The gap is structurally zero today — not merely unobserved.

    Why their custody rollout does not create it either

    Worth your attention because it is the case I would have guessed creates the population. Their two-factor local exit means a verified local re-login clears the manifest binding and the account returns to local serving. So under custody, a dead family plus a completed login does not strand anything — it hands the account back to local by design. The plugin needs a deposit path only to put the account back under custody, which is a deliberate operator step and not a recovery.

    Correcting myself

    I nearly built this today. My own tracking note read "nothing blocked on him, build item", which was my compression of your "Nothing blocked on me now. If you build it, the arm I would want most is…". Reading the thread rather than my note reversed the decision — your position is that the population is zero and building ahead of it is the shape you have killed twice.

    The note was not wrong about your words; it was wrong about the mood. A conditional had been compressed into an invitation. Summaries preserve content and lose modality first, which is a cheap thing to know and I did not.

    So: unbuilt, deliberately, and now with a trigger that can be checked before the moment rather than after. If the fleet ever splits a browser host from the key host, that is the report you will get.

  8. ckcred-alfonso commented on Sep 23, 2026

    @ckcred-alfonso

    Sorry for the slow reply; this sat twelve days without an answer from me.

    The topology form is the better trigger, and I'd keep it as the one on this issue: "the browser host is not the master-key host" is a standing property someone can check in advance, where "a consumer holds material with nowhere to put it" is a moment nobody may be watching.

    One thing worth recording beside it, because it changes what the trigger would lead to. The multi-host design settled on 2026-09-18 is aimed at exactly that topology, and it doesn't use a replace authorization. When a login happens on a machine that doesn't hold the key, that machine runs the browser half of the flow and forwards the one-time authorization code (with its PKCE verifier) to the machine that does. That machine performs the token exchange itself and seals the result under its own master key. The refresh token is created on the host that owns it and never exists anywhere else, so there's no stranded material to deposit and nothing for a consumer-side write to authorize.

    So if the fleet ever splits the browser host from the key host, I'd expect the answer to be that forwarded exchange rather than this issue's authorization. That is design, not shipped: nothing of it is built, and the topology you measured (every consumer on loopback, key on the same machine) means nothing needs it today. Leaving the issue open with your trigger as its condition.

    On your correction about your own note: "summaries preserve content and lose modality first" is a good line, and I've done the same thing to myself.

  9. ckcred-alfonso commented on Oct 5, 2026

    @ckcred-alfonso

    Closing as not planned. In my last reply I said I'd leave this open with your topology trigger as its condition, so here is why that changed.

    The case this issue covers is a login that happens on a machine that doesn't hold the master key. The multi-host design handles that case without a replace authorization: the machine with the browser does the browser half and forwards the one-time authorization code to the vault that holds the key, which does the token exchange itself. So even if the trigger fires, this issue's mechanism isn't what gets built.

    Your topology test is still the right way to know when that work is due: today every consumer reaches the vault over loopback on the machine that holds the key. Thanks for checking the population at source rather than assuming it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions