Docs: a root in a browser and its backup, any network's names, the console with no shell
Claude · 2026-10-28 · 4cfe4c9090d446871eb9aaabf6cb31a7937c759e · parents 308ece6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
README.md | 26 ++++++++++++- _meta/identity.md | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- site/README.md | 2 +- site/names/README.md | 8 ++++ 4 files changed, 172 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md index 623515b..b80ab53 100644 --- a/README.md +++ b/README.md@@ -178,6 +178,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).| `pyrtan console --out FILE` | Writes the console as one static HTML file, to host anywhere; there the browser is a hub of its own until it joins one. | | `pyrtan id new` | Makes your identity: a root key and the signed document hubs will hold. `id device add pyr:…` delegates to a device key, `id revoke` takes it back, `id route add HOST` says where you can be reached, `id name alice@domain` signs a name claim for a domain to serve (`--into` a checkout for a name at pyrtan.com), `id check` checks an identifier or any of those documents, `id show` shows them. | | `pyrtan allow WHO --may ABILITIES --at HUB` | Grants an identity (`pyr:…`, or a name the hub resolves and pins) abilities on your hub: `ask`, `chat`, `run`, `operate`. `allowed` lists the grants; `disallow` takes them back. Every command that calls a hub takes `--sign` to sign as your identity instead of (or beside) `--token`. |+| `pyrtan names init DIR --domain D` | Writes a names repository for a network: a README, `names/`, and for GitHub (or GitLab) the gate and Pages. `names gate` judges a change to names owned by their keys; `hub --names DIR` serves them. `id backup` / `restore` keep your key encrypted with a passphrase; `id release` gives a name up. || `pyrtan id resolve NAME@DOMAIN` | What identity a name is, checked against the key's own signature and pinned the first time; a changed answer is refused until `--repin`. `id pins` lists the pins; `id wellknown DIR --domain D` builds `/.well-known/pyrtan/` for a domain to serve names. | `serve` and `hub` take `--token` (or `$PYRTAN_TOKEN`) to require@@ -328,8 +329,18 @@ signature against without asking anybody. `pyr:<key>@host` (a hub, andomain vouches for. No domain owns an identity, and none grants anything by it: each hub keeps its own list of who may do what.+**No shell needed.** The console's **Identity** tab makes your identity+in the browser and hands you its backup at once, encrypted with your+passphrase, to download or keep in your own private GitHub repository;+restores it in another browser; claims a name, at pyrtan.com (GitHub's+own page opens the pull request from your account) or at any network's+names repository; and pairs your other devices by a code each shows and+a note it gets back. The **Access** tab keeps a hub's grants. The same,+from a shell:+``` pyrtan id new # your root key, ~/.config/pyrtan/identity/+pyrtan id backup --out backup.json # encrypted with a passphrase; restore with pyrtan id restorepyrtan id device add pyr:… --may chat,run --label phone pyrtan id name alice@pyrtan.com --github <your numeric GitHub id> pyrtan id check pyr:…@hub.example@@ -345,6 +356,17 @@ wellknown DIR --domain D` builds the files). pyrtan.com hands outaccount, and the gate checks the account's numeric id ([`site/names/README.md`](site/names/README.md)).+**Any network can run its own names**, in any git repository, owned by+their keys: a new name is open to whoever signs it, it moves only with a+handover its old key signs, and is given up only by a release it signs.+The gate reads nothing but signatures, so it runs on any git host.++```+pyrtan names init names-repo --domain names.example.org --host github # or gitlab, or plain+pyrtan id name alice@names.example.org --into names-repo # the first name+pyrtan hub --names names-repo/names --names-domain names.example.org # hubs can serve them too+```+**Grants** replace handing out the token. A hub grants identities abilities: `ask` (the routed paths), `chat` (the lobby), `run` (be a trusted member and run its trees) and `operate` (what the token allows);@@ -660,7 +682,9 @@ hubs it follows, bots made of trees, a typing indicator, and lobbies open to thetoken, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Identities (keys, device notes, name claims, names at pyrtan.com and pins, grants on hubs, the console's-key, `pyrtan id` and `pyrtan allow`) are built. Not yet built: hosting.+key, `pyrtan id` and `pyrtan allow`) are built, and all of it works from+the console with no shell; encrypted key backups, and names any network+runs in its own git repository, too. Not yet built: hosting.See [`_meta/design.md`](_meta/design.md) for the runtime's design and [`_meta/platform.md`](_meta/platform.md) for pyrtan.com. diff --git a/_meta/identity.md b/_meta/identity.md index 053b94f..cc073f3 100644 --- a/_meta/identity.md +++ b/_meta/identity.md@@ -2,10 +2,10 @@| | | |---|---|-| **Status** | **Built, v0.14, in three steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; and the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key. What is left is in §10, *Later*. |+| **Status** | **Built, v0.14 and v0.15, 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.15, 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*. || **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** | Built: `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`. |+| **Where** | Built: `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.15, the console's Identity and Access tabs, `pyrtan id backup|restore|release`, `pyrtan names init|gate`, `pyrtan hub --names`, `tests/test_console_identity.py` and `tests/browser/console_gui.cjs`. |## 0. The brief@@ -190,13 +190,69 @@ phone's console) gets its own key, and the root signs a note for it:one machine; devices are for everything else. The console's device key is a non-extractable `CryptoKey` in IndexedDB.-Pairing a phone: the console's Identity tab makes its key and shows the-device identifier and the command to run; `pyrtan id device add pyr:…---may chat,run --label phone` on the machine with the root prints the-note; the console takes it pasted. An unpaired key signs as itself, so a-hub can also grant the key directly. A QR code of the identifier, and-picking the note up from the root's hub instead of pasting it, are-later (§10).+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.15)++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:++```json+{"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@@ -270,6 +326,57 @@ wellknown DIR --domain D --out SITE` builds them from a directory ofclaims; 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.15)++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:++- a new name is open to whoever signs a claim to it, while nobody holds it;+- a name moves to a new key only with a **handover**: the old key signs the+ new claim's body first (`pyrtan id name … --handover-from OLD-KEY`), and+ the new key signs the claim with the handover in it;+- a name is given up only by a **release** its key signs, put in place of+ the claim (`pyrtan id release`); a released name is free, and is not+ served;+- a name's identity document is replaced only by a newer one, and not+ deleted while the name stands with the same key: an older document, or+ none, could bring back a device its owner revoked.++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:++- *GitHub*: a gate on `pull_request_target`, so the workflow and pyrtan+ come from the default branch and a pinned ref, never from the pull+ request, comparing the pull request with its merge base; and Pages,+ building `/.well-known/pyrtan/` with `pyrtan id wellknown`.+- *GitLab*: a `.gitlab-ci.yml` with the gate (against the merge base) and+ Pages. A merge request's pipeline runs the merge request's own copy of+ the file, so a reviewer reads any change to it; the generated file says+ so.+- *plain*: the files, and the README's commands for whatever runs CI.++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@@ -449,7 +556,16 @@ hub that checks Alice with a shared secret can also forge Alice.the console in Chromium end to end (a key, a note, a reload, an answer with no token, a trusted member). This is the step that replaces handing out the token.-- **Later:** a QR code for pairing, and the note picked up from the+- **Fourth, built (v0.15): no shell, backups, any network's names.**+ `identity.py` seals and opens backups, and signs releases and+ handovers; `names.py` judges names owned by their keys, writes a names+ repository for GitHub, GitLab or anything, and a hub serves one;+ `identity.js` signs every document a root signs and writes and opens the+ same backups, agreeing with Python byte for byte; the console's Identity+ tab makes, backs up and restores a root, claims names and pairs devices,+ and its Access tab keeps a hub's grants. `tests/browser/console_gui.cjs`+ drives all of it in three Chromium contexts, checked by Python's code.+- **Later:** passkeys to open a backup (§5a); a QR code for pairing, and the note picked up from theroot's hub; device keys for the CLI; 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@@ -482,6 +598,18 @@ hub that checks Alice with a shared secret can also forge Alice.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.+- **A forgotten passphrase** leaves the backup a locked box: nothing in+ this design opens it. The identity lives on in any browser or machine+ that still holds the key; from there, a new backup with a new+ passphrase (`pyrtan id backup`, or a restore and a download).+- **A browser's data cleared before its backup was saved** loses a root+ made there. The tab says *save your backup now* until it is saved, and+ that is all it can do.+- **Script injected into the console's page** could sign as the root held+ there while the page is open (§5a); it cannot carry the key away.+- **A network's repository on GitLab** runs a merge request's own CI+ file; a reviewer who merges a change to it unread lets the change judge+ itself (§6a).## 12. What this design does not claim diff --git a/site/README.md b/site/README.md index 21fcf95..9b71d24 100644 --- a/site/README.md +++ b/site/README.md@@ -7,7 +7,7 @@ static directory and publishes it.| Path | What | Built from | | :-- | :-- | :-- | | `/` | the landing page, with the live directory read from the listed hubs in the visitor's browser | `site/index.html` |-| `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it). Until it joins a hub the browser is one: it holds the catalog's trees (`/catalog/trees.json`) and runs them in the page. To join any hub started with `--cors https://pyrtan.com`, give its URL and token; its Member tab then joins that hub, and runs its trees when joined with the token or as a key the hub grants `run`; its Identity tab holds that key | `pyrtan console --out` |+| `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it). Until it joins a hub the browser is one: it holds the catalog's trees (`/catalog/trees.json`) and runs them in the page. To join any hub started with `--cors https://pyrtan.com`, give its URL and token; its Member tab then joins that hub, and runs its trees when joined with the token or as a key the hub grants `run`; its Identity tab makes, backs up and restores an identity, claims names and pairs devices, with no shell | `pyrtan console --out` || `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, `index.json` the command reads, and `trees.json`, the same trees as JSON for the console | `pyrtan index examples catalog --copy`, through `site/gate.py` | | `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` | diff --git a/site/names/README.md b/site/names/README.md index c509e8d..90c3da8 100644 --- a/site/names/README.md +++ b/site/names/README.md@@ -13,6 +13,14 @@ identity is the key, and works without a name.## Getting one+**In a browser:** open the console's Identity tab+(<https://pyrtan.com/console/>), *Make my identity*, save your backup,+then *Claim a name* with your GitHub login. GitHub's own new-file page+opens with `site/names/<login>.json` filled in; choose *Propose changes*+and *Create pull request*, from that GitHub account.++**From a shell:**+pip install 'pyrtan[identity]' pyrtan id new # once; back up the key it names pyrtan id name <login>@pyrtan.com --into <a checkout of this repository>