pyrtan / sourcev0.19.0 · main

pyrtan / _meta / identity.md

34,446 bytes · last changed 2026-10-28 in Merge main (agents beside the swarm, 0.18.0) into identity without a shell; this is release 0.19.0

Design — Identity: one key, every hub, any domain

StatusBuilt, v0.14 and v0.19, in four steps (§10): identifiers, the three documents and pyrtan id; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key; and, in v0.19, all of it from the console with no shell, a root's encrypted backup (§5a), and names any network runs in any git repository (§6a). What is left is in §10, *Later*.
Date2026-10-27
DecisionA 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.
WhereBuilt: src/pyrtan/identity.py (the encoding, documents, signing and checking), src/pyrtan/names.py (resolving, pins, /.well-known/pyrtan/, pyrtan.com's gate), pyrtan id in src/pyrtan/cli.py, site/names/, site/gate.py, tests/test_identity.py, tests/test_names.py. src/pyrtan/grants.py (who may do what on a hub), src/pyrtan/hub.py, federation.py and service.py (signed requests, /allow, /identity, members as identities), src/pyrtan/js/identity.js and page.py (the console's key), tests/test_grants.py, tests/conformance/identity.json and identity.cjs, tests/browser/console_identity.cjs; since v0.19, the console's Identity and Access tabs, `pyrtan id backuprestorerelease, pyrtan names initgate, pyrtan hub --names, tests/test_console_identity.py and tests/browser/console_gui.cjs`.

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

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:

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.

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:

{"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:

{"pyrtan-device": 1, "root": "pyr:…", "device": "pyr:…",
 "label": "phone", "may": ["ask", "chat", "run"],
 "issued": 1761580800, "expires": 1793116800, "sig": "<root's>"}

The console's device key is a non-extractable CryptoKey in IndexedDB. Pairing a phone: on the phone, the console's Identity tab (*Use this browser as a device*) makes its key and shows its code; on the browser that holds the root (§5a), *Devices* takes the code, the abilities and a label, and shows the signed note, which the phone takes pasted. pyrtan id device add pyr:… --may chat,run --label phone does the same from a shell. An unpaired key signs as itself, so a hub can also grant the key directly. A QR code of the code and of the note, and picking the note up from the root's hub instead of pasting it, are later (§10).

5a. A root in a browser, and its backup (v0.19)

The root was meant to live somewhere quiet: a file on one machine, or offline. That asks for a shell, and a shell turns people away. So the console can hold a root too, and everything pyrtan id does is a button.

Making one. WebCrypto makes the Ed25519 key *extractable once*: its seed is read out, sealed into a backup with the person's passphrase, and the key is imported again non-extractable; the seed is wiped and the page never holds an extractable key again. The tab says *save your backup now* until it is downloaded or committed. Restoring is the reverse: the backup file and its passphrase, opened by WebCrypto, the key imported non-extractable.

The backup is one JSON file, the same from the console (identity.js) and the shell (pyrtan id backup), each opening the other's:

{"pyrtan-key-backup": 1, "id": "pyr:…", "made": 1761580800,
 "kdf": {"name": "PBKDF2-SHA256", "iterations": 600000, "salt": "…"},
 "cipher": {"name": "AES-256-GCM", "iv": "…"}, "data": "…",
 "document": { the identity document, signed, in the clear }}

data is the 32-byte seed under AES-256-GCM, with pyrtan-key-backup-v1\n and the identifier as associated data, so a ciphertext cannot be passed off as another identity's. 600,000 rounds of PBKDF2-SHA256 is what OWASP asks of it; a backup claiming fewer than 100,000 is refused, so a doctored file cannot make a passphrase cheap to guess. PBKDF2 and not Argon2, because WebCrypto has PBKDF2 and nothing memory-hard, and the console writes nothing cryptographic of its own.

Where it may live. Somewhere only its owner can read: a file on their disk, or their own *private* repository (the console opens GitHub's page to make one, and its new-file page to commit the backup; git keeps every version, which here is a feature). Never anywhere public, and never as a hub's blob (members.md §4: nothing secret is ever a blob): an encrypted key that anyone can copy can be guessed at offline, forever, and git history means it is never really gone. The tab says so beside both buttons.

What this trades. A root in a browser is a key that script on the console's origin could *use*, though never read out: an injected script on that page could sign as you while the page is open. The console is static, carries no third-party script and is served by your own hub or by pyrtan.com's Pages; that is the bargain, and the shell's root, a file that never meets a browser, remains for whoever wants it quiet.

Later: passkeys. WebAuthn's PRF extension can give the console a secret from a passkey (Face ID, a fingerprint, a security key), synced by the platform's own keychain, to seal the backup instead of a passphrase. Support is uneven across browsers today; the passphrase stays, and a passkey would be a second way to open the same backup.

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:

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

pyrtan id name alice@example.org signs a claim with your root key, for the domain to serve.

Pinning. The first identity a name resolves to is remembered (in the hub's state file, the console's storage, pins.json beside the CLI's key). pyrtan id resolve alice@example.org resolves, pins and shows the identity document if the domain serves one; --repin takes a changed answer; pyrtan id pins lists them and --forget drops one. 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 with two rules only pyrtan.com adds, and beside it, optionally, site/names/<login>.id.json, the identity's document:

The gate also checks every claim's signature, that it is for <its file's name>@pyrtan.com, and that a document beside it is signed by the same key. pyrtan id name alice@pyrtan.com --into <checkout> writes both files, looking the numeric id up from the login (or taking --github), and runs the gate. The pages build runs pyrtan id wellknown site/names --domain pyrtan.com, which 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 each document at /.well-known/pyrtan/id/<56>.json. A change is live within the hour, the build's cadence. The Pages upload stays on actions/upload-pages-artifact@v3: v4 leaves out dot-directories.

Other domains do the same by serving the same paths, and pyrtan id wellknown DIR --domain D --out SITE builds them from a directory of claims; 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.

6a. Any network's names, in any git repository (v0.19)

pyrtan.com is one name provider. A network can be another: its names are a git repository on any host, served by any static host or by its own hubs, judged by a rule that needs nothing from the host.

Names owned by their keys (names.gate_key, pyrtan names gate). The gate compares two trees, the change and its merge base, and reads only signatures, so it runs in any CI, or on a patch that arrived by mail:

pyrtan.com keeps its own rule (§6): a name is your GitHub login, owned by your numeric GitHub id.

A repository (pyrtan names init DIR --domain D --host github|gitlab|plain) is pyrtan-names.json (the domain), a README, names/, and for its host:

The gate installs pyrtan from a ref the repository pins (--pyrtan-ref, main by default, with a comment to pin a commit). Nothing in such a repository is secret: no invite, no token, no backup.

Served by hubs. pyrtan hub --names DIR --names-domain D answers /.well-known/pyrtan/name/<name>.json, names.json and the identity documents from a checkout of the repository, reread when a file changes, so a network with no static host needs none: its hubs are its name providers, reachable at https://D.

From the console, *Claim a name* takes the network's name and its repository; for one on GitHub it opens the new-file page filled in, and for another host it gives the file to add.

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.

The audience is the hub's federation id, or the URL it was started at (--advertise, else its own address), and GET /api names it, so a client asks once and signs for it. The body is the canonical JSON the request carries; a POST with no body is signed as {}, which is what the hub reads it as. A bad signature is refused (401) on any hub. On a hub with a token, a good one may do what its root is granted and nothing else (403 otherwise, naming what it may do); on a hub without a token, where anyone may do anything, a key only says who is asking, which is what decides whether a member it signs for is trusted.

Grants. A hub keeps a list (grants.json beside its --state, or --grants FILE, or in memory), from --allow at start and from its operator at /allow:

pyrtan hub --allow alice@pyrtan.com=ask,run --allow pyr:…=operate
pyrtan allow bob@example.org --may chat --at HUB        # GET/POST /allow
pyrtan allowed --at HUB
pyrtan disallow bob@example.org --at HUB                # POST /disallow

A name is resolved and pinned when the grant is made; from then on the grant is held against the identifier, and the name is never resolved again. /allow and /disallow need the token or a key granted operate, even on a hub with no token: a grant of run makes a trusted member, which an open hub never had, so there grants come only from --allow at start. What a request may do is the grant's abilities intersected with the device note's may (grants.py, ability_for says which a path needs):

AbilityWhat it lets the identity do on this hubWhat it stands in for today
ask/intent, /run, /resume on a hub behind a tokenthe token, for asking
chata seat in a lobby behind a token; its bots answer what /intent would, so it is as good as askthe token, for chat
runfollow as a member and be handed trees (members.md §7)joining with the token
operateeverything the token allowsthe 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. POST /identity takes a granted root's newer document from anyone (it is signed); GET /.well-known/pyrtan/id/<56>.json serves the documents the hub holds, so a hub is a route for the identities it grants. The CLI signs with --sign (or $PYRTAN_SIGN) on every command that calls a hub, as the root key in $PYRTAN_IDENTITY; device keys for the CLI are later.

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 (trusted_by: key); one without is a keeper that says who it is. When its note expires, its root's document revokes it, or its root loses run, the hub drops the link; a member trusted by the token is not touched.

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.

9. Paths considered and not taken

10. Sequencing

11. Failure modes, honestly

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.