Identity Store Specification, v6

Table of Contents

The stack | v1 | v2 | v3 | v4 | v5 | v6 | v7

Status

Speculative. Nothing here is built. It answers one question: if the store had to be handed to another agent as a specification, with no access to the store, the tools or the session that made them, what would the specification have to say for that agent to build something compatible from first principles?

"Sealed" means the reader gets this document and nothing else. So it defines every term it uses, it prefers one rule to two, and where the store it was written from has two shapes for one thing it picks one.

The notation is Z in ASCII, loosely: given sets in brackets, schemas as named blocks of declarations over a line and predicates under it, a prime for the state after an operation. It is used to be exact, not to be checked; no tool has read it.

Words used as in RFC 2119: MUST, MUST NOT, SHOULD, MAY.

This is one of a stack of drafts, v2 to v7, each the whole of the specification and each the one before it with one change. v1 was sealed on 2026-10-10 and is not edited. Drafts are sealed one at a time, lowest first, each in a repository of its own, and are not edited once sealed. What review of a sealed draft finds, and what a build against it finds, is recorded beside it and carried into the draft above before that one is sealed. Section Changes says what this draft adds; section Review says what its reviewer is asked.

This draft: v6, the proxy route table.

1. Scope

In scope: where a credential is filed, what a filed credential contains, what is recorded about it in the clear, who can decrypt it, and the operations that change any of those.

Out of scope, on purpose: how a provider's sign-up works; which providers exist; how an agent is told which identity it is; transport of secrets to another machine; backup. Recording where a credential is held, when it is held somewhere other than the store, is in scope (section 5). A conforming build MAY add these and MUST NOT need them to pass section 8.

2. Given sets and types

[SECRET]            opaque text; never compared, logged or printed
[SEG]               a path segment: one or more of  a-z 0-9 . _ @ -
[DATE]              a calendar date, YYYY-MM-DD
[KEYID]             an encryption key's identifier

Kind    ::= login | otp | recovery | token | key | pin
Effect  ::= private | staged | outward
State   ::= held | wanted | elsewhere | dropped
Class   ::= person | agent | role | project | site
Env     ::= local | dev | test | staging | prod | shared

A SEG MUST NOT be a member of Kind. That one restriction is what makes a path parse without knowing its owner.

What each kind is. The definitions are the specification; the examples are not.

kind definition issued by revocable
login what is typed to begin a session as the account holder by change
otp a shared secret from which time-based codes are derived provider by reset
recovery a value the provider accepts in place of a lost factor provider by reissue
token a credential the provider issued to act as the account without a session provider yes, singly
key key material whose loss cannot be repaired by asking anyone holder no
pin a short value that is the holder's own and is asked for by others nobody no

The last two columns decide the kind when the provider's own word for a thing disagrees. A provider's "app password" is a token. A provider's "access key and secret" is a token. A passport number is a pin.

3. Paths

One shape, for every owner:

path    = owner "/" realm "/" handle "/" kind [ "/" name ]

owner   = "me"
        | "agent/" SEG                  ; a named agent identity
        | "role/" SEG                   ; an identity defined by what it does
        | "proj/" SEG "/" Env           ; a project, then its environment
        | "site/" SEG                   ; a place: a network, a building

realm   = SEG                           ; where the credential is honoured
handle  = SEG                           ; who the realm thinks this is
kind    = "login" | "otp" | "recovery" | "token" | "key" | "pin"
name    = SEG

Rules, each of which a validator can check from the path alone:

P1
Exactly one segment of a path is a Kind.
P2
name MUST be present for token, MUST be absent for login, otp and recovery, and MAY be present for key and pin.
P3
realm is the provider's registrable domain when it has one (github.com, not GitHub, not gh), in normal form (P8). When it has none, it is host.<name> for a service on a named machine, net.<name> for a network, or doc for something that is not an account at all. A name the provider serves only on a private network, such as one under .internal, is a realm as it is spelled in DNS.
P4
handle is what the realm calls the account, in normal form (P8). When the realm has no accounts, handle is the literal _. Words such as default, admin or master are handles only when the realm really has an account of that name. When normal form changes the handle (case, or a character outside SEG), the realm's own spelling is kept whole in the account field.
P8
Normal form. A realm is lower case, has no trailing dot, and an internationalised name is written as its A-label (xn--bcher-kva.example for bücher.example). A handle is lower case, and each character outside SEG becomes -. Normal form MUST be injective within a realm: if two accounts at one realm would normalise to one handle, the second uses the realm's numeric account id instead.
P5
A token's name is the holder: the machine or the service that presents it (laptop, ci, staging-jail). What it may do is a field (section 4), never part of the name. Two holders MUST have two tokens.
P6
For key, name is <algorithm-or-use>-<holder>-<yyyy-mm>.
P7
The same account is the same owner/realm/handle prefix for all of its kinds. An account's entries are found by listing a prefix.

Whose it is:

O1
A credential that lets the bearer act as an account is owned by the identity the account belongs to.
O2
A credential that identifies an application or a deployment to a provider is owned by proj/<project>/<environment>.
O3
A credential that admits anyone standing in a place (a wireless passphrase, a door code) is owned by site/<place>.
O4
No two owners share an entry. If two need the same access, the provider issues two credentials.

Environments:

N1
A project uses the members of Env it has; a library may have only test and shared. shared holds what every environment of the project presents to one provider, such as a domain registrar, and is its own owner.
N2
No environment reads another. A reader of proj/p/dev is not thereby a reader of proj/p/prod or of proj/p/shared, and a build MUST NOT fall back from a missing entry in one environment to the same path in shared.
N3
Only a project has environments. A role reaches an environment through the scope of a token it holds, never through its path.

4. Entries

Entry
  path     : Path
  secret   : SECRET
  fields   : FieldName -+-> TEXT
  ----------------------------------------------------------
  line 1 of the stored text is secret and nothing else
  every later line is  name ": " value
  dom fields  is a subset of  FieldName

FieldName is closed:

field required for value
issued every entry DATE
expires token; else optional DATE, or the literal never
effect token, login Effect
scope token the provider's own words for what it permits
used-by token what presents it, in a sentence
url optional where it is typed or managed
account optional another name the realm knows the account by (an email, an id)
fictional optional yes when the secret on line 1 was made up
note optional one line
generation a derived secret a positive integer: the counter the secret was derived with
derived-from a derived secret the path of the entry the secret is derived from
also a copied secret (W4) a Holder that has the same secret; repeatable
E1
A reader of an entry MAY rely on line 1 and on the fields above and on nothing else.
E2
An otp secret is an otpauth:// URI, whole.
E3
A recovery entry holds every unspent code, one per line after line 1 as code: <value>, and line 1 is the first unspent. Spending a code removes it. Removal is by value, never by position.
E4
One entry holds one thing. A real value and a made-up value MUST NOT share an entry.
E5
effect is the worst thing the bearer can do, not the usual thing: a token that can publish is outward even if it never has.
E6
An entry is derived when its secret is computed from another entry's secret and a counter, not issued by the provider or chosen by the holder. A derived entry has generation and derived-from; no other entry has either.
E7
derived-from names a path in dom entries. The source need not be readable by the derived entry's readers; deriving is then a custodian's operation (S3), and the readers hold only the result.

5. The record in the clear

Record
  path     : Path
  state    : State
  due      : DATE | none
  effect   : Effect | none
  reason   : TEXT                     ; required when state = dropped
  where    : Holder | none            ; required when state = elsewhere

Holder   = runtime ":" id             ; e.g. ci:example-org/workshop, agentrt:role-builder

Federation
  owner    : Owner
  runtime  : TEXT
  subject  : TEXT                     ; the runtime's own name for the workload
  grants   : TEXT                     ; what the provider lets that subject assume

Store
  entries     : Path -+-> Entry
  records     : Path -+-> Record
  federations : P Federation
  readers     : Owner --> P KEYID
  ----------------------------------------------------------
  I1   dom entries = { p : dom records | records(p).state = held }
  I2   for all p in dom entries:  records(p).due    = entries(p).expires
  I3   for all p in dom entries:  records(p).effect = entries(p).effect
  I4   for all p in dom entries:  P1..P8 hold of p,  E1..E7 of entries(p)
  I5   for all o:  readers(o) is not empty
  I6   for all p in dom entries:
          entries(p) is decryptable by exactly  readers(owner(p))
  I7   for all p in dom records:
          records(p).state = elsewhere  <=>  records(p).where /= none
  I8   no f in federations has f.owner = me;
       no p with owner(p) = me has state elsewhere or a field also

Promotion:

W1
A credential is promoted in one of three ways, in this order of preference. Federate: the runtime is trusted to act as the owner and the provider issues it short-lived credentials; the store holds a Federation and no secret. Issue another: the runtime is a new holder, so it gets its own token (P5), recorded as elsewhere with where naming the runtime. Copy: only under W4.
W2
A Federation has no entry and no secret. It is revoked at the provider, by removing the trust, and removed from the record when the provider refuses the subject.
W3
Nothing under me/ is promoted, by any of the three (I8), and no login is. A runtime that asks for one is asking for the person.
W4
A secret is copied to a second holder only when the provider issues one secret per account. The entry then has also, one Holder per line, and note says why it was copied.

The record exists so that three questions are answered without decrypting anything: what is held, what ends when, and what could be done with it. I2 and I3 say the record is a projection of the entries: a build MUST derive it, or check it, mechanically. A record kept by hand is not conforming, however careful the hand.

6. Isolation

S1
readers is per owner. readers(me) and readers(agent/x) are different sets unless someone decided otherwise and wrote down why.
S2
A process acting as owner o is given a key in readers(o) and no other. That it cannot read me/ is then a fact about keys, not about where it was told to look.
S3
A custodian MAY be a reader of every owner. This is the "one custodian, fanned out by issuing" arrangement: the custodian holds everything and hands out by issuing a narrower credential, never by sharing an entry.
S4
A secret that reaches a tool reaches it on standard input, in the environment of exactly one child process, or not at all: injected by a proxy into a request the tool makes (R1-R6). It MUST NOT be a command argument, a file in a checkout, or a line of output. Of the three, a proxy SHOULD be preferred where the tool can be pointed at one.
S5
An empty read is a failure. A tool that reads nothing MUST stop; it MUST NOT continue with an empty value, because the tool it feeds will then fall back to whatever ambient credential the machine has.

A proxy's routes:

Route
  caller   : Owner                    ; who is asking; authenticated (R3)
  host     : SEG                      ; where the request goes, normal form (P8)
  entry    : Path                     ; the secret to insert
  inject   : header NAME TEMPLATE | query NAME | basic
  allow    : P (METHOD x PATTERN)     ; requests the route will carry
  effect   : Effect

Store
  routes   : P Route
  ----------------------------------------------------------
  I9   for all r in routes:
          r.entry in dom entries
          realm(r.entry) = realm-of(r.host)  and  owner(r.entry) = r.caller

realm-of(h) = the registrable domain of h, or h itself when an entry is
              filed under h as its realm (a private name, P3)
R1
A route joins its entry by name: the entry's realm is the realm of the route's host (api.github.com joins github.com), compared as strings after normal form. That is the join P8 makes possible.
R2
A route does not cross owners: its caller is the owner of its entry (I9). Two callers that need one realm have two entries (O4).
R3
The caller is established by the proxy, from a credential the caller presents to the proxy alone (a client certificate, a token valid only at the proxy), never from a header the caller sets.
R4
The proxy is the holder. The entry a route inserts is a token named for the proxy (P5): token/proxy-laptop, and a second proxy in a runtime gets token/proxy-<runtime>, never a copy.
R5
allow narrows. A request whose method and path match no member of allow is refused at the proxy, whatever the token's scope would permit.
R6
effect is enforced at the wire. A request on an outward route is held until a person approves it; a staged route MAY be carried at once. A route's effect is at least as severe as what its allow can reach, and no more severe than its entry's.

The proxy reads every route's entry, so it is a custodian (S3) as a process. It MUST NOT run as, or be readable by, any caller it serves, and its log names caller, route and entry for every request and never the value inserted.

7. Operations

Each is given as what must be true before and what is true after. A build names them as it likes.

Enrol     (p : Path; s : SECRET; f : fields)
  pre    p not in dom entries;  P1..P8 of p;  f complete for kind(p)
  post   entries' = entries + {p |-> (s, f)};  records'(p).state = held

Issue     (account; holder : SEG; scope; expires : DATE; effect)
  pre    the provider has issued a token to this holder and no other
  post   Enrol(account/token/holder, ...);  records'(p).due = expires

Read      (p : Path; consumer)
  pre    p in dom entries;  consumer's key in readers(owner(p))
  post   Store unchanged;  consumer holds secret;  nothing printed

Rotate    (p : Path; s' : SECRET)
  pre    p in dom entries;  the provider already honours s'
         today >= entries(p).issued
         p derived  =>  s' is derived with entries(p).generation + 1
  post   entries'(p).secret = s';  entries'(p).issued = today
         p derived  =>  entries'(p).generation = entries(p).generation + 1
         every Holder in entries(p).also holds s'        <- checked, not assumed
         the old secret is refused by the provider      <- checked, not assumed

Spend     (p : Path; c)                 ; kind(p) = recovery
  pre    c is a code of entries(p)
  post   c is not a code of entries'(p)

Revoke    (p : Path; why : TEXT)
  pre    p in dom entries;  the provider refuses the secret
  post   p not in dom entries';  records'(p) = (dropped, why)

Rename    (p, q : Path)
  pre    p in dom entries;  q not in dom entries;  owner(p) = owner(q)
  post   entries'(q) = entries(p);  p not in dom entries'
         every consumer that named p names q             <- or the rename is not done

Audit     ()
  post   Store unchanged;  reports every violation of I1..I9

Two of these carry the lessons that cost the most to learn:

  • Rotate and Revoke are not finished when the store changes. They are finished when the provider has been asked with the old secret and has said no.
  • Rename moving an entry across owners is not a rename. It is a Revoke and an Issue, because O4 says the two owners never held the same credential.

8. Conformance

A build conforms when Audit reports nothing on a store it made, and when it gives these answers.

Paths, accepted:

me/github.com/jdoe/login
agent/nova/pypi.org/nova-dev/token/laptop
agent/nova/doc/_/key/ssh-laptop-2026-10
proj/workshop/staging/github.com/workshop-staging/token/staging-jail
site/home/net.guest/_/login
me/doc/_/pin/passport

Paths, refused, with the rule:

nova/pypi.org/nova-dev/token/laptop          no such owner         (grammar)
agent/nova/pypi.org/nova-dev/token           token without a name  (P2)
agent/nova/pypi.org/nova-dev/login/web       login with a name     (P2)
agent/nova/pypi/nova-dev/login               realm not a domain    (P3)
agent/nova/key/ssh/laptop                    kind not after handle (grammar)
agent/nova/pypi.org/nova-dev/token/publish   name is a scope       (P5)
me/host.box/token/token/laptop               a segment is a Kind   (given sets)
me/github.com./jdoe/login                    trailing dot          (P8)
me/github.com/j.doe+ci/login                 not in SEG            (P8: j.doe-ci)
proj/workshop/production/github.com/workshop/token/ci   not an Env  (grammar)
role/builder/prod/github.com/jdoe/token/builder-laptop  kind not after handle (grammar, which is N3)

Paths, accepted since v2:

me/xn--bcher-kva.example/jdoe/login                 an A-label realm
role/builder/llm-gateway.internal/_/token/builder-laptop

Behaviour:

C1
Enrolling a token without expires, effect, scope and used-by is refused.
C2
After any operation, the record equals the projection of the entries (I1..I3).
C3
A process holding only an agent's key cannot decrypt any path under me/. Tested by trying.
C4
Read with the store unreadable exits non-zero and starts no child process.
C5
No operation's command line, as seen in a process listing, contains a secret.
C6
Spend on a store with codes a b c, asked to spend b, leaves a c, whatever order the entry lists them in.
C7
Enrolling JDoe at a realm where jdoe is already enrolled for a different account is refused, or is enrolled under the realm's numeric id (P8, injective).
C8
Read of proj/p/dev/r/h/login when only proj/p/shared/r/h/login exists fails as an empty read (S5); it does not return the shared secret (N2).
C9
Rotate of a derived entry at generation 3 with a secret derived at generation 2, or at 5, is refused; at 4 it succeeds and the entry reads generation: 4.
C10
An entry with generation and no derived-from, or with a derived-from naming no entry, is reported by Audit (E6, E7).
C11
A record line in state elsewhere with no where is reported by Audit (I7).
C12
A Federation with owner me, or an entry under me/ with also, is refused (W3, I8).
C13
Rotate of an entry with also: ci:example-org/workshop is not reported done until that holder's copy has been replaced and the provider refuses the old secret.
C14
A route to host api.github.com with an entry under github.com is accepted; the same route with an entry under gitlab.com, or under GitHub.com in a store that let one in, is refused (R1, I9).
C15
A route with caller role/reviewer and an entry under role/builder/ is refused (R2).
C16
A request carrying a header that names a caller, from a process that presented no caller credential to the proxy, is refused (R3).
C17
On a route with effect: outward, a request matching allow is held and not sent until approved; one matching no member of allow is refused (R5, R6). Neither appears in the proxy's log with the inserted value.

9. Left open

Stated so that a builder does not mistake silence for a decision.

  • Whether the record is a file, a database or generated on demand.
  • Whether readers is enforced by encryption recipients, by separate stores, or by a broker that holds the only key. S2 is satisfied by any.
  • How one identity's several handles are tied together. P7 ties an account's kinds; nothing ties nova-dev at one realm to nova-ci at another except the owner above both. An index from identity to accounts may be wanted and is not specified.
  • Second factors that are hardware. They are not entries: there is nothing to store. Whether the record lists which keys are registered where is left open, and it is the thing a second custodian would most need.
  • A mailbox is the root of recovery for most accounts under an owner. The specification treats it as one more login. It probably deserves a rule of its own.
  • Whether pin should exist. Its contents are not credentials, most cannot be rotated, and E4 with the fictional field exists only because of it. A wallet and a credential store may be two things that share an encryption key.

Changes from the version below

Newest first. Each entry names the rules it touches, so a reviewer can read only those.

v6 from v5: the proxy route table

S4 lets a secret reach a tool in the environment of one child process. That still hands the tool the secret. A local proxy that holds the entry and inserts it into the requests a consumer makes keeps the consumer from ever holding it, and turns "where is the token" into "what does the proxy need to know", which is a table. The table has to join to the store by name, which is why it comes after v2.

  • Route (new schema, §6) and Store.routes.
  • a third way for a secret to reach a request: injected by a proxy.
  • a route joins its entry by realm and owner; the caller is authenticated; the proxy is the holder; allow narrows; effect is enforced at the wire.
  • C14-C17.

v5 from v4: where a promoted credential lives

v1 had a state elsewhere and nowhere to say where. Moving a role from a laptop to a hosted runtime (a CI runner, an agent runtime with its own credential vault) happens in one of three ways: the runtime is trusted to act as the role and is issued short-lived credentials (federation), the runtime is a new holder and gets its own token (P5), or the provider issues one secret per account and the value is copied. v1 could record only the second, and then only as "not here".

§1
moving secrets stays out of scope; recording where they are is in.
Record
a where component, required for elsewhere.
Federation (new schema, §5)
a record line with no entry, keyed by owner.
FieldName
also, for the copied case; twelve, closed.
W1-W4 (new, §5), I7, I8
where is said; the person is never promoted; a copy is the exception and says why.
Rotate
not finished until every holder in also has the new secret.
§8
C11-C13.

v4 from v3: generations

v1's Rotate assumed the new secret came from the provider. Some do not: a password derived from a master secret and a counter, a database password a build computes, a key stretched from a passphrase. For those the store holds the counter, and v1 had nowhere to put it and nothing to stop it going backwards, which reissues a secret already retired.

FieldName
two more, generation and derived-from; eleven, closed.
E6, E7 (new, §4)
when the two fields are present, and that a derived entry's source is named by path.
Rotate
for a derived entry, the generation rises by exactly one; for any entry, issued never goes back.
§8
C9, C10.

v3 from v2: environments

v1 put an environment in every project's path and left its values open, so prod, production and live were three owners for one thing.

Env (new given set)
closed: local dev test staging prod shared.
grammar
proj/ SEG / Env.
N1-N3 (new, §3)
what shared means; environments never cross; a role reaches an environment through a token's scope, not its path.
§8
two refused paths, C8.

Nothing else moves: O4 already made a move between environments a Revoke and an Issue, because the environment is part of the owner.

v2 from v1: names in normal form

v1's SEG already forbids upper case, so P3's "not GitHub" held, but a trailing dot passed, an internationalised name had no spelling, and P4's "verbatim, lower case" contradicted itself for any realm that keeps case or allows characters outside SEG.

P8 (new)
one normal form for realms and handles, injective within a realm.
P3, P4
point at P8; P4 keeps the realm's own spelling in account; P3 admits a private DNS name as a realm.
I4, Enrol
P1..P8.
§8
two refused paths, two accepted, C7.

Why it comes first: everything stacked on it joins on names. v5 keys runtime records by path and v6 joins a route to an entry by realm; both are only as good as two spellings of one name being one string.

Review

Answers are recorded beside the sealed draft and carried into the draft above it before that one is sealed.

  1. R2 keeps a route inside one owner. A proxy serving several roles then holds one entry per role per realm. Is that the right cost, against a proxy that maps several callers to one entry (which O4 forbids)?
  2. May me be a caller? A person's own tools through the same proxy would put me/ entries in reach of a process that also serves agents, which S3 allows only for a custodian.
  3. inject is per provider. Should it live on the route, as here, or on the entry as a field, so that the entry alone says how it is presented?
  4. Git over HTTPS and SSH do not pass through an HTTP proxy this way; a credential helper and an SSH agent do the same job. Should they read the same routes, and is that in this specification's scope?

What this would not have caught

A specification of where secrets are filed says nothing about the 26 .env files found the same day, which are the larger exposure, nor about secrets pasted into a working session. A build could conform to every line above on a machine where an agent can read a production token from a checkout. Section 6 is necessary and nowhere near sufficient.