Design note: identity as a key, reachable at any domain, named by any domain
Claude · 2026-10-27 · 92a57749d18bb3c0a596ccef9336950f995e36b3 · parents d269e46
Proposes pyr:<key> identifiers (Ed25519, onion-style base32 with a SHA3 checksum), pyr:<key>@host locators and name@domain names resolved through /.well-known/pyrtan/ and pinned; device notes and revocation; pyrtan.com names by pull request, gated on the GitHub numeric user id; signed requests bound to the receiving hub; per-hub grants that can stand in for the token; and cryptography as an optional extra. Nothing is built yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
_meta/identity.md | 422 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+)
diff --git a/_meta/identity.md b/_meta/identity.md new file mode 100644 index 0000000..8052311 --- /dev/null +++ b/_meta/identity.md@@ -0,0 +1,422 @@+# Design — Identity: one key, every hub, any domain++| | |+|---|---|+| **Status** | **Proposed.** Nothing here is built. This note settles the format and the rules before any code; §10 says in what order the code would come. |+| **Date** | 2026-10-27 |+| **Decision** | A person (or a hub, or a device) is a public key. The identifier is that key, written so that anyone can check a signature from it without asking anybody. Domains, hubs and onion services are where an identity can be *reached* and what it can be *called*, never what it *is*: `pyr:<key>` is the identity, `pyr:<key>@host` says where to find it, and `alice@pyrtan.com` is a name a domain vouches for. pyrtan.com hands out names by pull request, as one name provider among any number. An identity grants nothing by itself: each hub keeps its own list of who may do what. |+| **Where** | Proposed: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/federation.py` and `hub.py` (signed requests, grants), `src/pyrtan/page.py` (the console's device key), `site/names/` and `site/gate.py` (pyrtan.com's names), `/.well-known/pyrtan/` on any domain. |++## 0. The brief++*"Can we create universal users for the whole pyrtan network with just+our GitHub pages? i.e. hash_id:pyrtan.com or other domains, onion routes+etc., basically all key based."*++Read as: someone should be one person across every hub they use, without+an account on any of them and without pyrtan.com running a server; the+part that identifies them should be a key, so that no domain owns them;+and a domain (pyrtan.com, their own, an onion service) should be a place+to find them and a name for them, not the identity itself.++Today there is no such thing. A hub's identity is its URL. A member's is+`urn:pyrtan:member:<hex>`, made fresh by each browser for each hub, with+an HMAC pair secret only that hub knows (`members.md` §2). Being trusted+means having been given the hub's bearer token, which is one secret for+the whole swarm (`design.md` §7). Chat has no names at all (`chat.md`+§4). Each of those was right for what it was built for; none of them+lets a hub say "Alice may ask, and Alice's phone may run trees" without+handing Alice the token.++## 1. What this design refuses++- **An account anywhere.** No hub, and not pyrtan.com, holds a user+ record that the user's identity depends on. A key is made on the+ person's own machine and never leaves it.+- **A domain that owns a person.** `user@server` identifiers (email,+ Matrix, ActivityPub) belong to whoever runs the server; lose the+ server and you are someone else. Here a domain can stop vouching for a+ name, and the identity is untouched.+- **pyrtan.com as the authority.** pyrtan.com is static files (`platform.md`).+ It can publish names; it cannot log anyone in, and the network must+ work for an identity pyrtan.com has never heard of.+- **Identity as a grant.** A valid signature proves who is asking. What+ they may do is the hub's own list (§7), the same way following is the+ one act of trust between hubs (`federation.md` §1). There is no+ network-wide permission.+- **Passwords, recovery codes, and recovery by anyone but the holder.**+ A lost root key is a lost identity (§9). Recovery by a third party is+ a third party who can become you.+- **Making anything that works today need an identity.** HMAC follows,+ networks, the token, anonymous members and anonymous chat all stay as+ they are. Identity is opt-in at each end.+- **This author's own signing code.** A private key is handled only by+ a maintained library or the browser (§8).++## 2. The identifier++An identity is an Ed25519 public key, 32 bytes, written as:++```+pyr:<base32( key || checksum || version )>+checksum = SHA3-256( ".pyrtan id" || key || version )[:2]+version = 0x01 (Ed25519)+base32 = RFC 4648, lowercase, no padding+```++That is 35 bytes, 56 characters after the prefix. The layout is Tor's+v3 onion address with Pyrtan's own checksum string and version, so it+is a known shape, and base32 and SHA3-256 are both in the standard+library: encoding and checking an identifier needs no dependency, only+signatures do. A vector, from RFC 8032 §7.1 test 1's public key+`d75a9801…f707511a`:++```+pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab+```++Rules:++- **Compare the whole string,** after lowercasing. Nothing shorter is an+ identifier. A display may shorten to `pyr:25nj…ooab`; a program never+ matches on that.+- **Refuse on a bad checksum,** a version other than one it knows, or a+ length other than 56. A typo is caught before any network call.+- **The key, not a hash of it.** For Ed25519 the two are the same length,+ and the key lets a verifier check a signature with nothing else in+ hand. Agility comes from the version byte: another algorithm is another+ version, with its own length.+- **It is a DNS label.** 56 lowercase base32 characters fit in 63, so an+ identifier can stand where a host name part does, should that ever be+ wanted. Nothing in this design depends on it.+- **Not an onion address.** The checksum string differs on purpose, so a+ `pyr:` identifier never parses as a `.onion` and back. An identity that+ is reachable over Tor says so as a route (§3), with the onion service's+ own key; one key for two jobs is how a compromise of one becomes both.++An identity is a **root** (a person or a hub), or a **device** (a key a+root has delegated to, §5). Both are written the same way; which one a+key is depends on the documents around it, not on the string.++## 3. Three forms++```+identity = "pyr:" key56+address = identity "@" locator+locator = host [ ":" port ] ; a DNS name, an .onion, or [ip literal]+name = local "@" domain+local = [a-z0-9] [a-z0-9._-]{0,62}+```++A string that starts `pyr:` is an identity or an address; anything else+with an `@` is a name. `@` and not `:` or `|`, because `:` already+separates ports and IPv6 groups and starts URLs, and `|` is a pipe in+every shell.++- **`pyr:<key>`** is who. Everything that grants, pins or logs uses this+ form and only this form.+- **`pyr:<key>@hub.example`**, **`pyr:<key>@<56>.onion`** say where. A+ locator is a host that will take messages for the identity: the hub+ that *is* the identity, or a hub that keeps a mailbox for it as a+ member. An identity may have any number; they are hints, carried in its+ document (§4), and a wrong one costs a failed delivery, never a+ forgery, because what comes back is checked against the key. A locator+ is reached at `https://<locator>`; an `.onion` at `http://`, since Tor+ already authenticates and encrypts to the onion's key.+- **`alice@pyrtan.com`**, **`alice@alice.example`**,+ **`alice@<56>.onion`** say what to call someone. A name resolves to an+ identity through the domain (§6), and the first answer is pinned.++## 4. Documents++Three kinds of signed JSON, each one standing alone, so that anyone can+hold a copy and nobody has to trust the holder:++```json+{"pyrtan-identity": 1, "id": "pyr:…", "kind": "person",+ "routes": ["hub.example", "<56>.onion"],+ "devices": [ <device notes, §5> ], "revoked": [ "pyr:…" ],+ "updated": 1761580800, "sig": "…"}+```++**Signing.** `sig` is the root's Ed25519 signature over+`"pyrtan-doc-v1\n"` followed by the document without `sig`, as the+canonical JSON hubs already sign (`federation.py`, `canonical()`: sorted+keys, no spaces, UTF-8). Encoded base64url without padding. Documents+hold strings, integers, lists and objects, never floats, so the Python+and JavaScript runtimes produce the same bytes. The domain string means+a document signature can never be replayed as a request signature (§7)+or the other way round.++**Which copy wins.** The highest `updated` among copies whose signature+holds. A copy from the future (more than the signature window, five+minutes, ahead) is refused rather than clamped: unlike a directory entry,+a document that wins forever would be a way to freeze revocation.++**Where copies live.** Any host may serve+`/.well-known/pyrtan/id/<56>.json`: the identity's own hub, the hubs it is+a member of, pyrtan.com for an identity with a name there (§6). An+identity document is not secret, so it is a fine blob (`members.md` §3):+a hub that has granted an identity keeps its document and hands it to its+keepers like any other.++`kind` is `person` or `hub`. It is what the identity says of itself, for+display; nothing branches on it.++## 5. Devices++The root key should live somewhere quiet: a file on one machine, mode+600, or offline. Each place the person actually uses (a laptop's CLI, a+phone's console) gets its own key, and the root signs a note for it:++```json+{"pyrtan-device": 1, "root": "pyr:…", "device": "pyr:…",+ "label": "phone", "may": ["ask", "chat", "run"],+ "issued": 1761580800, "expires": 1793116800, "sig": "<root's>"}+```++- **`may`** caps what the device can ever be granted; a hub grants the+ intersection of its own list and this (§7). A phone that only chats+ says `["chat"]`, and stealing it gets nobody further.+- **`expires`** is at most a year after `issued`. A note past it is+ refused; the root renews by signing a new one.+- **Revocation** is the device id in the identity document's `revoked`,+ under a newer `updated`. A hub that holds the document re-reads it+ (from a route, or its keepers) at least hourly and on any request from+ a device it has not seen in a day; a revoked device's links are dropped+ and its requests refused.+- **A root may also sign requests itself.** That is the simple case for+ one machine; devices are for everything else.++The console's device key is a non-extractable `CryptoKey` in IndexedDB.+Pairing a phone: the console makes its key and shows the device+identifier (and a QR code of it); `pyrtan id device add pyr:… --may+chat,run --label phone` on the machine with the root prints the note; the+console takes it pasted or scanned, or picks it up from the root's hub.++## 6. Names++`alice@example.org` resolves by fetching++```+https://example.org/.well-known/pyrtan/name/alice.json+```++which is a claim signed by the identity it names:++```json+{"pyrtan-name": 1, "name": "alice", "domain": "example.org",+ "id": "pyr:…", "issued": 1761580800, "sig": "<id's>"}+```++The resolver checks that `name` and `domain` are what it asked for and+that `sig` holds under `id`. The self-signature is what stops a domain,+or someone registering at it, from pointing a name at a key whose holder+never claimed it. The domain's part is serving the file; the key's part+is signing it; both have to agree.++**Pinning.** The first identity a name resolves to is remembered (in the+hub's state file, the console's storage, `~/.config/pyrtan/`). A later+answer naming a different identity is not followed: it is shown beside+the pinned one, and the operator re-pins by hand. This is SSH's+known-hosts rule, and it is what makes a domain's word safe to take:+pyrtan.com, or anyone, can remap a name, and every client that saw the+old answer notices.++**pyrtan.com's names.** GitHub Pages serves `/.well-known/` like any other+path and sends `Access-Control-Allow-Origin: *`, so the console can+resolve names from a browser. The names are files in this repository,+`site/names/<login>.json`, each a name claim plus two fields only+pyrtan.com uses:++- the name is the author's **GitHub login**, lowercased. One person, one+ name, no disputes to judge, and GitHub has already checked it;+- `"github": <numeric user id>`. GitHub frees a login when its owner+ renames, so the gate (`site/gate.py`, run by the `catalog` workflow as+ it is for namespaces) checks the pull request author's *numeric* id+ (`github.event.pull_request.user.id`) against the file, not the login.+ A new file must be added by that user; a changed or removed one+ likewise, or by a maintainer.++The gate also checks the claim's signature and the identifier's+checksum. The `pages` build publishes each claim at+`/.well-known/pyrtan/name/<login>.json`, an index of all of them at+`/.well-known/pyrtan/names.json` for mirrors, and, if the file carries+one, the identity's latest document at `/.well-known/pyrtan/id/<56>.json`.+A change is live within the hour, the build's cadence.++**Other domains** do the same by serving the same paths; nothing about+them is pyrtan.com's business. An operator can serve names from their own+hub. An onion service can serve names to anyone who fetches over Tor.++## 7. On the wire++**Signed requests.** A request made as an identity carries, beside or+instead of the HMAC headers:++```+X-Pyrtan-Key: pyr:… the signing key (a device or a root)+X-Pyrtan-Date: 1761580800 as today+X-Pyrtan-Key-Signature: <base64url> Ed25519 over the message below+X-Pyrtan-Device: <base64url note> when the key is a device+```++```+message = "pyrtan-req-v1\n" + audience + "\n" + METHOD + "\n" + path + "\n" + date + "\n" + sha256(canonical body)+```++which is the HMAC message (`federation.py`, `sign()`) with a domain+string and the **audience**, the receiving hub's own `id`, in front. A+request signed for one hub cannot be replayed at another; within one hub+the five-minute window is the same as HMAC's. The hub checks the+signature, the device note (signed by its root, unexpired, not revoked),+and then its grants.++**Grants.** A hub keeps a list, in its state file and from+`--allow`:++```+pyrtan hub --allow alice@pyrtan.com=ask,run --allow pyr:…=operate+```++A name is resolved and pinned when the grant is made; from then on the+grant is held against the identifier. What a request may do is the+grant's abilities intersected with the device note's `may`:++| Ability | What it lets the identity do on this hub | What it stands in for today |+| :-- | :-- | :-- |+| `ask` | `/intent`, `/run`, `/resume` on a hub behind a token | the token, for asking |+| `chat` | a seat in a lobby behind a token | the token, for chat |+| `run` | follow as a member and be handed trees (`members.md` §7) | joining with the token |+| `operate` | everything the token allows | the token |++The bearer token keeps working. A hub may have a token, grants, both or+neither; a request with no key headers is judged exactly as it is today.++**Members.** A member may follow with its device key as its actor id.+The `Follow` is signed with the key as well as carrying the pair secret+as now; once accepted, the link runs on HMAC exactly as today, and the+key is not needed again until the hub re-reads the identity document.+That keeps signatures off the polling path and leaves `members.md` §2+otherwise unchanged. A member whose root holds `run` is trusted; one+without is a keeper.++**Hubs.** A hub may have a root key too: `/actor` gains `key` and a+signature over `{id, key}`. A follower pins the key. A hub that moves+(new box, new domain, now an onion service) and serves the same key at+its new `/actor` is recognised as the same hub, which is the case+`members.md` §3 handles today only by keeping the same name.++## 8. Signatures: where the code comes from++The standard library has no public-key signatures, and `federation.md`+§7 already declined to write Ed25519 by hand for directory entries,+because a follow gave the same property. Here nothing else gives it: a+hub that checks Alice with a shared secret can also forge Alice.++- **Python: `cryptography`,** as an optional extra, `pyrtan[identity]`,+ the way Playwright is `pyrtan[browser]`. Imported lazily by+ `identity.py`. Without it, encoding and checking identifiers still+ works (§2); `pyrtan id new`, signing and verifying say how to install+ the extra; a hub started with a `pyr:` or name grant refuses to start,+ loudly, rather than accept nothing; a hub with no grants ignores key+ headers and behaves as today. Pyrtan's core stays standard-library+ only, as `CONTRIBUTING.md` requires; identity is opt-in at each hub,+ and so is its dependency.+- **The browser: WebCrypto's `Ed25519`,** shipped in all three engines,+ available only in secure contexts. The console at+ `https://pyrtan.com/console/` and on any hub behind TLS has it; a+ console served over plain `http://` on a LAN does not, and falls back+ to today's anonymous member. That is the same limit that made the+ console carry its own SHA-256 and HMAC (`members.md` §2), and it is+ said in the Member tab rather than worked around.+- **The fallback, if the dependency proves a problem:** a verify-only+ Ed25519 in pure Python, from RFC 8032's reference code. Verifying+ handles no secret, so timing does not leak anything; the risk is+ accepting a forgery through a missed check, which RFC 8032's and+ Wycheproof's vectors are for. Signing would still never be pure Python.+ Not taken now; written down so that the choice is a choice.++## 9. Paths considered and not taken++- **`user@server` as the identity** (email, Matrix, ActivityPub's+ actors). The server owns the user. Kept as the *name* form (§6), where+ pinning makes it safe.+- **A hash of the key** (`hash_id`). Same length for Ed25519, and a+ verifier then needs the key carried separately. The version byte gives+ the agility a hash would have.+- **`did:key`.** A standard, and a `pyr:` identifier converts to one and+ back mechanically. But it is base58btc (another codec, mixed case, not+ a DNS label) and arrives with the DID resolution machinery. A+ conversion can be offered later; it is not the format.+- **Nostr's `npub`.** Close in spirit; bech32 is a codec the standard+ library does not have, and base32 plus SHA3 is.+- **The onion encoding exactly.** Would make an identity and an onion+ address the same string, and invite one key for both (§2).+- **GitHub as the identity** (OAuth, or `github.com/<login>.keys` as the+ registry). OAuth needs a server for the exchange, or makes every hub+ ask GitHub who someone is; `.keys` makes GitHub the authority and has+ no place for a phone's key. GitHub is used once, by the gate, to say+ who opened a pull request, which is all pyrtan.com's names need.+- **A global namespace** (a chain, a DHT of names). Names are per domain+ on purpose: there is nothing global to squat on or to fight over.+- **`<id>.pyrtan.com`.** GitHub Pages serves no wildcard subdomains, and+ the path form does the same job.++## 10. Sequencing++- **First: the identifier and the documents.** `identity.py`: encode,+ decode, checksum, canonical document signing and checking; `pyrtan id+ new`, `show`, `check`, `device add`, `revoke`; the root key in+ `~/.config/pyrtan/identity/`. Tests with RFC 8032's vectors and the+ vector in §2, and conformance vectors (`tests/conformance/`) so the+ console's checking agrees with Python's.+- **Second: names.** `site/names/`, the gate's checks, the build's+ `/.well-known/pyrtan/` output, `pyrtan id resolve alice@pyrtan.com`,+ pinning. pyrtan.com hands out names; nothing yet grants by them.+- **Third: hubs.** Signed requests, `--allow` and grants, members+ following with a device key, revocation refresh, the console's device+ key and pairing. This is the step that replaces handing out the token.+- **Later:** hub keys in `/actor` and moves; a chat seat that opts in to+ showing a verified name (chat stays anonymous by default, and still+ keeps nothing); reaching `.onion` locators, which needs a SOCKS proxy+ the transport does not have and an egress rule for it (`io.md`);+ signing directory entries, now that keys exist, if a reason appears.++## 11. Failure modes, honestly++- **A lost root key is a lost identity.** Device notes expire within a+ year and nothing can renew them. The person makes a new root, re-pairs+ their devices, and asks each name provider and each hub operator to+ point at it. The CLI says this when it makes a root, and offers to+ write a backup.+- **A stolen device** works until it is revoked and the hubs re-read the+ document: up to an hour, plus however long the new document takes to+ reach a route the hub asks. `may` bounds what it can do meanwhile.+- **A stolen root** is the identity, stolen. The thief can revoke the+ real devices and sign a new document. Nothing in this design takes the+ identity back; the person starts again and tells people, out of band.+- **A name provider remaps a name.** Pinned clients refuse to follow and+ show both identities. A client that never resolved the name before+ gets the new answer; that is what a name is.+- **A GitHub login changes hands.** The gate holds the numeric id, so+ the new holder of the login cannot edit the old claim. The old claim+ stays until its owner or a maintainer removes it.+- **pyrtan.com is down or gone.** Identifiers, documents held by hubs,+ and every pin keep working. Only resolving a name for the first time+ needs the domain.+- **Clocks.** The same five-minute window as HMAC; a device whose clock+ is off is refused with `signature too old`, as a hub is today.+- **A console on plain `http://`** cannot hold a key (§8) and joins as an+ anonymous member.++## 12. What this design does not claim++It does not claim anonymity: an identity is a stable pseudonym, linkable+across every hub it signs to, which is the point. A person who wants two+unlinkable selves makes two roots. It does not claim a name is a real+person, or a key a trustworthy one; it claims only that the same key+signed both times. It does not claim recovery. It does not claim that+an identity can do anything on a hub whose operator did not grant it, or+that granting it is safe; a grant is trust, as a follow is, and the trace+and the logs will name whom it was given to.