Identity Store Specification, v2

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: v2, names in normal form.

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. 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

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 "/" SEG           ; 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.

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
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.

5. The record in the clear

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

Store
  entries  : Path -+-> Entry
  records  : Path -+-> Record
  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..E5 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))

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 or in the environment of exactly one child process. It MUST NOT be a command argument, a file in a checkout, or a line of output.
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.

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'
  post   entries'(p).secret = s';  entries'(p).issued = today
         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..I6

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)

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).

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.

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. Is replacing characters with - safe enough, given P8's fallback to a numeric id? Escaping schemes that are not injective have already made two paths into one name elsewhere (see the note on naming for isolation).
  2. Should P8 name a dated copy of the Public Suffix List as the reference for "registrable domain", or leave the judgement to the person filing?
  3. Is lower-casing a handle ever wrong for a realm whose accounts differ only by case? If so, the numeric id is the handle there and P8 says so.

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.