Merge pull request #34 from b-i-t-8/claude/trusting-dirac-noavo4
b-i-t-8 · 2026-10-27 · 5bbf036e44cce0de61e22273ae4906ad77b08092 · parents b86a805 682194f
Identity without a shell, encrypted key backups, and names any network runs; release 0.19.0
Changes
README.md | 28 +++- _meta/identity.md | 148 +++++++++++++++++++-- pyproject.toml | 2 +- site/README.md | 2 +- site/names/README.md | 8 ++ src/pyrtan/__init__.py | 2 +- src/pyrtan/cli.py | 147 ++++++++++++++++++++- src/pyrtan/hub.py | 48 ++++++- src/pyrtan/identity.py | 148 ++++++++++++++++++++- src/pyrtan/js/identity.js | 119 ++++++++++++++++- src/pyrtan/names.py | 341 ++++++++++++++++++++++++++++++++++++++++++++----- src/pyrtan/page.py | 327 +++++++++++++++++++++++++++++++++++++++++------ tests/browser/console_gui.cjs | 124 ++++++++++++++++++ tests/conformance/identity.cjs | 18 +++ tests/conformance/identity.json | 202 +++++++++++++++++++++++++++++ tests/conformance/make_identity_vectors.py | 17 +++ tests/test_console_identity.py | 98 ++++++++++++++ tests/test_identity.py | 63 +++++++++ tests/test_names.py | 163 +++++++++++++++++++++++ uv.lock | 2 +- 20 files changed, 1906 insertions(+), 101 deletions(-)
diff --git a/README.md b/README.md index e787ebd..474e2a4 100644 --- a/README.md +++ b/README.md@@ -182,6 +182,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@@ -346,8 +347,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@@ -363,6 +374,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);@@ -720,7 +742,7 @@ site/ pyrtan.com: the landing page, the hub list, the catalog## Status-0.18.0. The core, the service, the hub, the command, the console, the+0.19.0. The core, the service, the hub, the command, the console, thecatalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation between hubs, networks (any hub starts one; the default one is built@@ -735,7 +757,9 @@ hubs it follows, bots made of trees, a typing indicator, lobbies open to the pubtoken, 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. Agents beside the swarm+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. Agents beside the swarm(open-weights models in the browser or on a server of your own that probe the trees, suggest branches, triggers and trees, chat, and keep a memory and skills; `/suggestions` and `pyrtan grow --suggestions`) are diff --git a/_meta/identity.md b/_meta/identity.md index 053b94f..a6b9138 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.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*. || **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.19, 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.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:++```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.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:++- 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.19): 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/pyproject.toml b/pyproject.toml index f4a8abd..478566c 100644 --- a/pyproject.toml +++ b/pyproject.toml@@ -1,6 +1,6 @@[project] name = "pyrtan"-version = "0.18.0"+version = "0.19.0"description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees." readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/site/README.md b/site/README.md index b803331..ad69523 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; built into pyrtan, published here as a mirror (and for the snapshot) | `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> diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index 233bac2..2c0f981 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py@@ -33,4 +33,4 @@ __all__ = ["Response", ]-__version__ = "0.18.0"+__version__ = "0.19.0"diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 0304109..d3c1649 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -31,6 +31,7 @@pyrtan id new|show|check|device|revoke|route|name your identity: a key, any domain pyrtan id resolve NAME@DOMAIN what identity a name is, pinned the first time pyrtan allow WHO --may ask,run --at HUB grant an identity abilities on your hub; allowed, disallow+ pyrtan names init DIR --domain D | gate a network's own names, in any git repository``serve`` and ``hub`` take ``--token`` (or ``$PYRTAN_TOKEN``) to require a bearer token, ``--cors ORIGIN`` to let a browser elsewhere call them, and@@ -183,6 +184,8 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)") p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)") p.add_argument("--allow", action="append", default=[a for a in os.environ.get("PYRTAN_ALLOW", "").split() if a], metavar="WHO=ABILITIES", help="grant an identity (pyr:… or name@domain, resolved and pinned now) abilities here: ask, chat, run, operate (repeatable; default $PYRTAN_ALLOW, space-separated)")+ p.add_argument("--names", metavar="DIR", default=os.environ.get("PYRTAN_NAMES"), help="serve the name claims in DIR (a checkout of a names repository, `pyrtan names init`) at /.well-known/pyrtan/, as this network's name provider (default $PYRTAN_NAMES)")+ p.add_argument("--names-domain", metavar="DOMAIN", default=os.environ.get("PYRTAN_NAMES_DOMAIN"), help="the domain the names are for (default $PYRTAN_NAMES_DOMAIN, else this hub's own host); the hub must be reachable at https://DOMAIN")p.add_argument("--grants", metavar="FILE", default=os.environ.get("PYRTAN_GRANTS"), help="keep the grants in this file (default $PYRTAN_GRANTS, else grants.json beside --state, else in memory)") p.add_argument("--lan", action="store_true", default=os.environ.get("PYRTAN_LAN", "").lower() in ("1", "true", "yes"), help="find hubs on this local network with no one to ask: announce this hub over UDP multicast (TTL 1) and try the hubs heard there when joining a network; needs --advertise with a LAN address (default $PYRTAN_LAN)") p.add_argument("--chat-reach", type=int, metavar="N", default=int(os.environ.get("PYRTAN_CHAT_REACH", "0") or 0), help="keep up to N chat links with other open lobbies this hub hears of (LAN, directory), so its strangers can meet theirs: for chat only, never a follow; needs an open lobby (--chat-public, or no token) (default $PYRTAN_CHAT_REACH or 0)")@@ -292,6 +295,18 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--into", required=True, metavar="CHECKOUT", help="a checkout of github.com/b-i-t-8/pyrtan (or any repository with a catalog/ directory)") p.add_argument("--owner", action="append", default=[], metavar="LOGIN", help="GitHub login(s) that own the namespace; needed when the namespace is new")+ p = sub.add_parser("names", help="a network's own names, in any git repository: make one, or run its gate")+ nms = p.add_subparsers(dest="names_command", required=True)+ q = nms.add_parser("init", help="write a names repository for DOMAIN: README, names/, and the gate and Pages for its git host")+ q.add_argument("dir", type=Path)+ q.add_argument("--domain", required=True, help="the domain the names are for: name@DOMAIN, served at https://DOMAIN/.well-known/pyrtan/")+ q.add_argument("--host", choices=["github", "gitlab", "plain"], default="github", help="github: Actions (pull_request_target) and Pages; gitlab: .gitlab-ci.yml and Pages; plain: neither (default github)")+ q.add_argument("--pyrtan-ref", default="main", metavar="REF", help="the pyrtan the gate installs (a branch, tag or commit; pin a commit you have read; default main)")+ q = nms.add_parser("gate", help="judge a change to a directory of names owned by their keys: HEAD against BASE")+ q.add_argument("head", type=Path, help="the names directory as the change leaves it")+ q.add_argument("--base", type=Path, help="the names directory the change starts from (its merge base); omit for a first commit")+ q.add_argument("--domain", required=True)+p = sub.add_parser("allow", help="grant an identity abilities on your hub: ask, chat, run, operate") p.add_argument("who", metavar="pyr:…|NAME@DOMAIN", help="an identity, or a name your hub resolves and pins now") p.add_argument("--may", required=True, metavar="ABILITIES", help="comma-separated: ask (the routed paths), chat (the lobby), run (be a trusted member), operate (what the token allows)")@@ -336,13 +351,23 @@ def main(argv: list[str] | None = None) -> int:q.add_argument("--github", type=int, metavar="USER_ID", help="your numeric GitHub user id, for a name at pyrtan.com (with --into, looked up from the name when omitted)") where = q.add_mutually_exclusive_group() where.add_argument("--out", help="write the claim here (default: stdout)")- where.add_argument("--into", type=Path, metavar="CHECKOUT", help="put the claim and your identity document into a checkout of this repository's site/names/, ready for a pull request")+ where.add_argument("--into", type=Path, metavar="CHECKOUT", help="put the claim and your identity document into a checkout of a network's names repository (pyrtan names init), or of this repository's site/names/, ready for a pull request")+ q.add_argument("--handover-from", type=Path, metavar="KEY-FILE", help="the key that holds the name now (its root.key): it signs the handover that moves the name to you")+ q = ids.add_parser("release", help="sign that you give up a name, to replace your claim with in the names repository")+ q.add_argument("name", metavar="NAME@DOMAIN")+ q.add_argument("--out", help="write the release here (default: stdout)")q = ids.add_parser("resolve", help="what identity a name@domain is, pinned the first time") q.add_argument("name", metavar="NAME@DOMAIN") q.add_argument("--repin", action="store_true", help="accept a different identity than the one pinned") q.add_argument("--json", action="store_true", help="print the claim, and the identity document if the domain serves one") q = ids.add_parser("pins", help="the names you have resolved, and the identity each is pinned to") q.add_argument("--forget", metavar="NAME@DOMAIN", help="drop a pin, so the next resolve takes what the domain says")+ q = ids.add_parser("backup", help="write your key, encrypted with a passphrase, and your identity document, to keep somewhere only you can read")+ q.add_argument("--out", required=True, help="the file to write (a private place: your disk, your own private repository; never a public one)")+ q.add_argument("--passphrase-env", metavar="VAR", help="read the passphrase from this environment variable instead of asking")+ q = ids.add_parser("restore", help="make this machine your identity again, from a backup (the console's or `pyrtan id backup`'s)")+ q.add_argument("file", help="the backup file")+ q.add_argument("--passphrase-env", metavar="VAR", help="read the passphrase from this environment variable instead of asking")q = ids.add_parser("push", help="give a hub your newest identity document, so it knows your devices and revocations now") q.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="the hub (default $PYRTAN_AT or the local hub)") q = ids.add_parser("wellknown", help="build /.well-known/pyrtan/ from a directory of name claims, for any domain to serve")@@ -359,7 +384,7 @@ def main(argv: list[str] | None = None) -> int:"search": cmd_search, "pull": cmd_pull, "index": cmd_index, "publish": cmd_publish, "console": cmd_console, "follow": cmd_follow, "join": cmd_join, "network": cmd_network, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory, "reports": cmd_reports, "keep": cmd_keep, "kept": cmd_kept, "fetch": cmd_fetch, "id": cmd_id,- "allow": cmd_allow, "allowed": cmd_allowed, "disallow": cmd_disallow,+ "allow": cmd_allow, "allowed": cmd_allowed, "disallow": cmd_disallow, "names": cmd_names,} return commands[args.command](args)@@ -1044,7 +1069,7 @@ def cmd_hub(args: argparse.Namespace) -> int:state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress, store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, suggestions=args.suggestions, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public, lan=args.lan, chat_reach=args.chat_reach,- allow=args.allow, grants=args.grants,+ allow=args.allow, grants=args.grants, names=args.names, names_domain=args.names_domain,) except IdentityError as e: print(f"pyrtan hub: {e}", file=sys.stderr)@@ -1540,6 +1565,7 @@ def cmd_id(args: argparse.Namespace) -> int:"new": _id_new, "show": _id_show, "check": _id_check, "device": _id_device, "revoke": _id_revoke, "route": _id_route, "name": _id_name, "resolve": _id_resolve, "pins": _id_pins, "wellknown": _id_wellknown, "push": _id_push,+ "backup": _id_backup, "restore": _id_restore, "release": _id_release,}[args.id_command](args, directory) except IdentityError as e: print(f"pyrtan id: {e}", file=sys.stderr)@@ -1627,6 +1653,10 @@ def _id_check(args: argparse.Namespace, directory: Path) -> int:print(f"{thing}: the identity document of {doc['id']}, signed by it; {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked") elif kind == "device": print(f"{thing}: {doc['device']} may {','.join(doc['may']) or 'nothing'} for {doc['root']}, until {time.strftime('%Y-%m-%d', time.localtime(doc['expires']))}")+ elif kind == "release":+ print(f"{thing}: {doc['id']} gives up {doc['name']}@{doc['domain']}, signed by it")+ elif kind == "backup":+ print(f"{thing}: a key backup of {doc['id']}, {doc['kdf']['iterations']} PBKDF2 rounds" + (", with its identity document" if doc.get("document") else "") + "; `pyrtan id restore` opens it")else: print(f"{thing}: {doc['id']} claims {doc['name']}@{doc['domain']}, signed by it") return 0@@ -1668,23 +1698,27 @@ def _id_route(args: argparse.Namespace, directory: Path) -> int:def _id_name(args: argparse.Namespace, directory: Path) -> int: from . import identity, names key, doc = _id_load(directory)+ network = names.repo_domain(args.into) if args.into else Nonegithub = args.github- if args.into and github is None:+ if args.into and network is None and github is None:local = identity.parse(args.name).name github = names.github_id(local) print(f"GitHub says {local} is user {github}", file=sys.stderr) extra = {"github": github} if github is not None else {}- claim = identity.make_name(key, args.name, **extra)+ old = identity.Key.load(args.handover_from) if args.handover_from else None+ claim = identity.make_name(key, args.name, handover_from=old, **extra)text = json.dumps(claim, indent=2, ensure_ascii=False) + "\n" if args.into: written = names.place(args.into, claim, doc)- problems = names.gate(args.into)+ problems = names.gate_key(args.into / "names", None, network) if network else names.gate(args.into)for p in problems: print(f"gate: {p}", file=sys.stderr) if problems: return 1 print("wrote " + " and ".join(str(w) for w in written))- print(f"now: git checkout -b names/{claim['name']} && git add {names.NAMES_DIR} && git commit && git push -u origin HEAD, and open a pull request from the GitHub account {claim['name']}")+ where = "names" if network else names.NAMES_DIR+ print(f"now: git checkout -b names/{claim['name']} && git add {where} && git commit && git push -u origin HEAD, and open a pull request"+ + ("" if network else f" from the GitHub account {claim['name']}"))return 0 if args.out: Path(args.out).write_text(text, encoding="utf-8")@@ -1694,6 +1728,19 @@ def _id_name(args: argparse.Namespace, directory: Path) -> int:return 0+def _id_release(args: argparse.Namespace, directory: Path) -> int:+ from . import identity+ key, _ = _id_load(directory)+ release = identity.make_release(key, args.name)+ text = json.dumps(release, indent=2, ensure_ascii=False) + "\n"+ if args.out:+ Path(args.out).write_text(text, encoding="utf-8")+ print(f"wrote {args.out}: {key.id} gives up {release['name']}@{release['domain']}; put it in place of names/{release['name']}.json")+ else:+ sys.stdout.write(text)+ return 0++def _id_resolve(args: argparse.Namespace, directory: Path) -> int: from . import identity, names claim = names.resolve(args.name)@@ -1745,6 +1792,64 @@ def _id_wellknown(args: argparse.Namespace, directory: Path) -> int:return 1 if problems else 0+def _passphrase(args: argparse.Namespace, *, twice: bool) -> str:+ import getpass+ if args.passphrase_env:+ value = os.environ.get(args.passphrase_env)+ if not value:+ raise IdentityError(f"${args.passphrase_env} is empty")+ return value+ first = getpass.getpass("passphrase: ")+ if twice and getpass.getpass("again: ") != first:+ raise IdentityError("the two passphrases differ")+ return first+++def _id_backup(args: argparse.Namespace, directory: Path) -> int:+ from . import identity+ key, doc = _id_load(directory)+ out = Path(args.out)+ if out.exists():+ raise IdentityError(f"{out} exists; a backup is not overwritten")+ backup = identity.make_backup(key, _passphrase(args, twice=True), document=doc)+ fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)+ with os.fdopen(fd, "w", encoding="utf-8") as f:+ f.write(json.dumps(backup, indent=2) + "\n")+ print(f"wrote {out}: {key.id}, encrypted with your passphrase, and your identity document")+ print("keep it where only you can read it (your disk, your own private repository), never in a public one: anyone with a copy can guess at the passphrase forever")+ return 0+++def _id_restore(args: argparse.Namespace, directory: Path) -> int:+ from . import identity+ try:+ backup = json.loads(Path(args.file).read_text(encoding="utf-8"))+ except (OSError, json.JSONDecodeError) as e:+ raise IdentityError(f"{args.file}: {e}") from None+ problem = identity.backup_problem(backup)+ if problem:+ raise IdentityError(f"{args.file}: {problem}")+ key_file = directory / "root.key"+ if key_file.exists():+ held = identity.Key.load(key_file)+ if held.id != backup["id"].lower():+ raise IdentityError(f"{directory} already holds {held.id}; move it away first, knowing that it is an identity")+ key = identity.open_backup(backup, _passphrase(args, twice=False))+ doc = backup.get("document") or identity.make_identity(key)+ current = None+ if (directory / "identity.json").exists():+ try:+ current = json.loads((directory / "identity.json").read_text(encoding="utf-8"))+ except (OSError, json.JSONDecodeError):+ current = None+ doc = identity.newest([doc, current], ident=key.id) or doc+ if not key_file.exists():+ key.save(key_file)+ _id_save(directory, doc)+ print(f"you are {key.id} again, in {directory}" + ("" if backup.get("document") else "; the backup held no identity document, so a new one lists no devices"))+ return 0++def _id_push(args: argparse.Namespace, directory: Path) -> int: _, doc = _id_load(directory) at = args.at.rstrip("/")@@ -1760,6 +1865,34 @@ def _id_push(args: argparse.Namespace, directory: Path) -> int:return 0+# ---------------------------------------------------------------- a network's names+++def cmd_names(args: argparse.Namespace) -> int:+ from . import identity, names+ try:+ if args.names_command == "init":+ written, kept = names.init_repo(args.dir, args.domain, host=args.host, pyrtan_ref=args.pyrtan_ref)+ for path in written:+ print(f"wrote {path}")+ for path in kept:+ print(f"kept {path} (already there)")+ print(f"next: commit it, push it to your git host, point {args.domain} at its Pages (or at a hub started --names {args.dir / 'names'}),"+ f" and claim the first name: pyrtan id name <you>@{args.domain} --into {args.dir}")+ return 0+ domain = identity.check_host(args.domain.lower())+ if not args.head.is_dir():+ raise IdentityError(f"{args.head} is not a directory")+ problems = names.gate_key(args.head, args.base, domain, label=args.head.name or "names")+ except IdentityError as e:+ print(f"pyrtan names: {e}", file=sys.stderr)+ return 2+ for p in problems:+ print(f"gate: {p}")+ print(f"gate: {'refused' if problems else 'open'} ({domain}, names owned by their keys)")+ return 1 if problems else 0++# ---------------------------------------------------------------- grants diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 005a230..03c1a39 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -50,6 +50,10 @@ may no longer be. ``GET``/``POST /allow`` and ``POST /disallow`` (operator)keep the grants; ``POST /identity`` takes a granted root's newer identity document from anyone, since it is signed; ``GET /.well-known/pyrtan/id/<56>.json`` serves the documents the hub holds.+With ``--names DIR`` a hub is also its network's name provider: it serves+the claims in DIR (``pyrtan names init`` makes a repository of them) at+``/.well-known/pyrtan/name/<name>.json``, ``names.json``, and their+identity documents, for its own domain.""" from __future__ import annotations@@ -146,8 +150,11 @@ class _HubResolver:class Hub:- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, chat_reach: int = 0, suggestions: str | Path | None = None) -> None:+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, chat_reach: int = 0, suggestions: str | Path | None = None, names: str | Path | None = None, names_domain: str | None = None) -> None:self.name = name+ self.names_dir = Path(names) if names else None+ self._names_domain = names_domain+ self._names_cache: tuple[tuple, Any] | None = Noneself.grants = grants if grants is not None else Grants() self.grants.on_change = self._grant_changed self._audience = audience@@ -194,6 +201,27 @@ class Hub:"""The id a key-signed request to this hub must be signed for.""" return self.federation.id if self.federation is not None else self._audience+ @property+ def names_domain(self) -> str | None:+ """The domain this hub's names are for: --names-domain, else its own host."""+ if self._names_domain:+ return self._names_domain+ from urllib.parse import urlsplit+ return urlsplit(self.audience).hostname if self.audience else None++ def names(self) -> "Any":+ """The directory of names this hub serves, read again whenever a file in it changes."""+ from .names import load_directory+ if self.names_dir is None or not self.names_domain:+ return None+ try:+ stamp = tuple(sorted((e.name, e.stat().st_mtime_ns, e.stat().st_size) for e in self.names_dir.iterdir()))+ except OSError:+ return None+ if self._names_cache is None or self._names_cache[0] != stamp:+ self._names_cache = (stamp, load_directory(self.names_dir, self.names_domain))+ return self._names_cache[1]+def _signer(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str]) -> tuple[identity.Signer | None, set[str], tuple[int, Any] | None]: """For a key-signed request: who signed it, what they may do here, or the refusal.""" if not identity.has_key_signature(headers):@@ -692,7 +720,19 @@ class Hub:if method == "GET" and path.startswith("/.well-known/pyrtan/id/") and path.endswith(".json"): ident = identity.PREFIX + path[len("/.well-known/pyrtan/id/"):-len(".json")] doc = self.grants.document(ident) if len(ident) == len(identity.PREFIX) + identity.ID_LENGTH else None+ named = self.names()+ if named is not None:+ held = [d for d in named.docs.values() if d["id"] == ident and (doc is None or d["updated"] > doc["updated"])]+ doc = max(held, key=lambda d: d["updated"]) if held else docreturn (200, doc) if doc else (404, {"status": "error", "reason": "no-identity", "say": "This hub holds no document for that identity."})+ if method == "GET" and (path == "/.well-known/pyrtan/names.json" or (path.startswith("/.well-known/pyrtan/name/") and path.endswith(".json"))):+ named = self.names()+ if named is None:+ return 404, {"status": "error", "reason": "no-names", "say": "This hub serves no names; start it with --names DIR."}+ if path.endswith("/names.json"):+ return 200, {"pyrtan-names": 1, "domain": self.names_domain, "names": named.claims}+ claim = named.claims.get(path[len("/.well-known/pyrtan/name/"):-len(".json")])+ return (200, claim) if claim else (404, {"status": "error", "reason": "no-name", "say": f"No such name at {self.names_domain}."})if method == "GET" and path == "/capabilities": return 200, self.capabilities() if method == "GET" and path == "/gaps":@@ -716,7 +756,7 @@ class Hub:if method == "GET" and path == "/pyrtan.js": return 200, ("text/javascript; charset=utf-8", PYRTAN_JS) if method == "GET" and path == "/api":- endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/suggestions", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json"]+ endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/suggestions", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json", "/.well-known/pyrtan/name/{name}.json", "/.well-known/pyrtan/names.json"]if self.lobby is not None: endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"] if fed is not None:@@ -830,6 +870,8 @@ def serve_hub(suggestions: str | Path | None = None, allow: list[str] | None = None, grants: str | None = None,+ names: str | Path | None = None,+ names_domain: str | None = None,chat_typing: bool = True, chat_public: bool = False, ) -> tuple[Hub, Server]:@@ -845,7 +887,7 @@ def serve_hub(for who, may in specs: granted.allow(who, may) local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, chat_reach=chat_reach, suggestions=suggestions)+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, chat_reach=chat_reach, suggestions=suggestions, names=names, names_domain=names_domain)server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), public=is_chat_path if chat_public else None, keyed=True).start() hub._audience = (advertise or server.url).rstrip("/") if federate or advertise or join or lan or chat_reach: diff --git a/src/pyrtan/identity.py b/src/pyrtan/identity.py index 54f2981..59922cb 100644 --- a/src/pyrtan/identity.py +++ b/src/pyrtan/identity.py@@ -43,6 +43,10 @@ VERSIONS = {VERSION_ED25519: 32}CHECKSUM_TAG = b".pyrtan id" DOC_TAG = b"pyrtan-doc-v1\n" REQUEST_TAG = "pyrtan-req-v1"+BACKUP_TAG = b"pyrtan-key-backup-v1\n"+HANDOVER_TAG = b"pyrtan-handover-v1\n"+BACKUP_ITERATIONS = 600_000 # PBKDF2-SHA256, as OWASP advises for it in 2023 and after+MIN_ITERATIONS = 100_000 # what a backup may claim, at the least, and still be readID_LENGTH = 56 WINDOW = 300 # seconds a document's time may run ahead of ours YEAR = 365 * 24 * 3600 # the longest a device note may last@@ -321,7 +325,7 @@ def make_device(root: Key, device: str, may: list[str], *, label: str = "", issuissued = int(time.time()) if issued is None else issued note = { "pyrtan-device": 1, "root": root.id, "device": normal(device), "label": label,- "may": _abilities(may), "issued": issued, "expires": issued + YEAR if expires is None else expires,+ "may": [x for x in ABILITIES if x in _abilities(may)], "issued": issued, "expires": issued + YEAR if expires is None else expires,} if note["device"] == root.id: raise IdentityError("a root does not delegate to itself; it signs for itself")@@ -477,14 +481,18 @@ def _reissue(doc: dict[str, Any], root: Key, **changes: Any) -> dict[str, Any]:# Name claims.-def make_name(key: Key, name: str, *, issued: int | None = None, **extra: Any) -> dict[str, Any]:- """``key`` claiming ``name`` (``local@domain``). ``extra`` is a domain's own fields (pyrtan.com's ``github``)."""+def make_name(key: Key, name: str, *, issued: int | None = None, handover_from: Key | None = None, **extra: Any) -> dict[str, Any]:+ """``key`` claiming ``name`` (``local@domain``). ``extra`` is a domain's own fields (pyrtan.com's ``github``).+ ``handover_from`` is the key that holds the name now: it signs the claim's body first, as+ ``handover``, which is how a name moves to a new key where names are owned by their key."""parsed = parse(name) if parsed.form != "name": raise IdentityError(f"{name!r} is not a name (name@domain)") claim = {"pyrtan-name": 1, "name": parsed.name, "domain": parsed.domain, "id": key.id, "issued": int(time.time()) if issued is None else issued, **extra} _plain(claim)+ if handover_from is not None:+ claim["handover"] = {"id": handover_from.id, "sig": _b64(handover_from.sign(HANDOVER_TAG + canonical(claim)))}return sign_document(claim, key)@@ -505,6 +513,47 @@ def check_name(claim: Any, *, name: str | None = None) -> str | None:return check_signature(claim, claim["id"])+def check_handover(claim: dict[str, Any], old: str) -> str | None:+ """None if ``claim`` carries ``old``'s handover; otherwise why not."""+ h = claim.get("handover")+ if not isinstance(h, dict) or not isinstance(h.get("sig"), str):+ return f"the name moves from {short(old)} to {short(claim.get('id', '?'))} without a handover signed by {short(old)}"+ if h.get("id") != old:+ return f"the handover is {short(str(h.get('id')))}'s, not {short(old)}'s"+ body = {k: v for k, v in claim.items() if k not in ("sig", "handover")}+ try:+ ok = verify(old, HANDOVER_TAG + canonical(body), _unb64(h["sig"]))+ except (IdentityError, ValueError):+ ok = False+ return None if ok else f"the handover is not signed by {short(old)}"+++def make_release(key: Key, name: str, *, issued: int | None = None) -> dict[str, Any]:+ """``key`` giving up ``name``: what replaces its claim, so the name is free again."""+ parsed = parse(name)+ if parsed.form != "name":+ raise IdentityError(f"{name!r} is not a name (name@domain)")+ release = {"pyrtan-name-release": 1, "name": parsed.name, "domain": parsed.domain, "id": key.id,+ "issued": int(time.time()) if issued is None else issued}+ return sign_document(release, key)+++def check_release(release: Any, *, name: str | None = None) -> str | None:+ if not isinstance(release, dict) or release.get("pyrtan-name-release") != 1:+ return "not a name release (pyrtan-name-release: 1)"+ try:+ normal(release.get("id"))+ _int(release, "issued")+ stated = parse(f"{release.get('name')}@{release.get('domain')}")+ except IdentityError as e:+ return str(e)+ if stated.form != "name" or (stated.name, stated.domain) != (release["name"], release["domain"]):+ return "name and domain are not a lowercase name@domain"+ if name is not None and str(parse(name)) != str(stated):+ return f"the release is for {stated}, not {name}"+ return check_signature(release, release["id"])++def check_any(doc: Any, *, now: float | None = None) -> tuple[str, str | None]: """Which kind of document this is, and None or what is wrong with it.""" if isinstance(doc, dict):@@ -514,7 +563,11 @@ def check_any(doc: Any, *, now: float | None = None) -> tuple[str, str | None]:return "device", check_device(doc, now=now) if "pyrtan-name" in doc: return "name", check_name(doc)- return "unknown", "not a pyrtan identity document, device note or name claim"+ if "pyrtan-name-release" in doc:+ return "release", check_release(doc)+ if "pyrtan-key-backup" in doc:+ return "backup", backup_problem(doc)+ return "unknown", "not a pyrtan identity document, device note, name claim, release or key backup"# ---------------------------------------------------------------- signed requests@@ -594,6 +647,93 @@ def verify_request(audience: str, method: str, path: str, body: Any, headers: direturn Signer(key, note["root"], note)+# ---------------------------------------------------------------- backups+#+# A root key, encrypted with a passphrase, beside the identity document it+# signs, so that a person can keep it somewhere durable (a file, their own+# private repository) and restore it on another machine or browser. The+# console writes the same format with WebCrypto (identity.js). It is for+# places only its owner can read: an encrypted key where anyone can copy+# it can be guessed at offline, forever.+++def _aesgcm_kdf():+ try:+ from cryptography.hazmat.primitives import hashes+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC+ except ImportError:+ raise IdentityError(INSTALL) from None+ return hashes, AESGCM, PBKDF2HMAC+++def _backup_key(passphrase: str, salt: bytes, iterations: int) -> bytes:+ hashes, _, PBKDF2HMAC = _aesgcm_kdf()+ return PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=iterations).derive(passphrase.encode("utf-8"))+++def make_backup(key: Key, passphrase: str, *, document: dict[str, Any] | None = None, salt: bytes | None = None,+ iv: bytes | None = None, iterations: int = BACKUP_ITERATIONS, made: int | None = None) -> dict[str, Any]:+ """``key`` encrypted with ``passphrase`` (PBKDF2-SHA256, then AES-256-GCM over the 32-byte+ seed, the identifier as associated data), with its identity document beside it."""+ if len(passphrase) < 8:+ raise IdentityError("a passphrase is at least 8 characters; a long sentence is better")+ _, AESGCM, _ = _aesgcm_kdf()+ salt = os.urandom(16) if salt is None else salt+ iv = os.urandom(12) if iv is None else iv+ data = AESGCM(_backup_key(passphrase, salt, iterations)).encrypt(iv, key._seed, BACKUP_TAG + key.id.encode("ascii"))+ backup = {+ "pyrtan-key-backup": 1, "id": key.id, "made": int(time.time()) if made is None else made,+ "kdf": {"name": "PBKDF2-SHA256", "iterations": iterations, "salt": _b64(salt)},+ "cipher": {"name": "AES-256-GCM", "iv": _b64(iv)}, "data": _b64(data),+ }+ if document is not None:+ if document.get("id") != key.id:+ raise IdentityError("the document is another identity's")+ backup["document"] = document+ return backup+++def backup_problem(backup: Any) -> str | None:+ """What is wrong with a backup's shape, before any passphrase is tried; None if nothing."""+ if not isinstance(backup, dict) or backup.get("pyrtan-key-backup") != 1:+ return "not a key backup (pyrtan-key-backup: 1)"+ kdf, cipher = backup.get("kdf"), backup.get("cipher")+ try:+ normal(backup.get("id"))+ except IdentityError as e:+ return str(e)+ if not isinstance(kdf, dict) or kdf.get("name") != "PBKDF2-SHA256" or not isinstance(kdf.get("iterations"), int) or not isinstance(kdf.get("salt"), str):+ return "the backup's kdf is not PBKDF2-SHA256 with iterations and a salt"+ if not MIN_ITERATIONS <= kdf["iterations"] <= 10_000_000:+ return f"the backup's kdf claims {kdf['iterations']} iterations; at least {MIN_ITERATIONS} are read"+ if not isinstance(cipher, dict) or cipher.get("name") != "AES-256-GCM" or not isinstance(cipher.get("iv"), str) or not isinstance(backup.get("data"), str):+ return "the backup's cipher is not AES-256-GCM with an iv and data"+ doc = backup.get("document")+ if doc is not None:+ problem = check_identity(doc, ident=backup["id"])+ if problem:+ return f"the document beside the key: {problem}"+ return None+++def open_backup(backup: Any, passphrase: str) -> Key:+ """The key in ``backup``; IdentityError for a wrong passphrase or a damaged file."""+ problem = backup_problem(backup)+ if problem:+ raise IdentityError(problem)+ _, AESGCM, _ = _aesgcm_kdf()+ try:+ salt, iv, data = _unb64(backup["kdf"]["salt"]), _unb64(backup["cipher"]["iv"]), _unb64(backup["data"])+ seed = AESGCM(_backup_key(passphrase, salt, backup["kdf"]["iterations"])).decrypt(iv, data, BACKUP_TAG + backup["id"].lower().encode("ascii"))+ except Exception:+ raise IdentityError("the passphrase does not open this backup (or the file is damaged)") from None+ key = Key(seed)+ if key.id != backup["id"].lower():+ raise IdentityError("the backup's key is not the identity it names")+ return key++# ---------------------------------------------------------------- where the CLI keeps them diff --git a/src/pyrtan/js/identity.js b/src/pyrtan/js/identity.js index 28084fe..5f22b41 100644 --- a/src/pyrtan/js/identity.js +++ b/src/pyrtan/js/identity.js@@ -3,8 +3,11 @@* What the console needs of _meta/identity.md, and what Python's * src/pyrtan/identity.py does the same way: identifiers (`pyr:` and the * base32 of key, SHA3-256 checksum and version), canonical JSON, the- * message a request signature is over, and signing it with a device key.- * tests/conformance/identity.json holds the vectors both must agree on.+ * message a request signature is over, and signing it; the documents a+ * root signs (identity documents, device notes, name claims, releases,+ * handovers); and the encrypted backup of a root key (PBKDF2-SHA256, then+ * AES-256-GCM, all by crypto.subtle). tests/conformance/identity.json+ * holds the vectors both must agree on.* * SHA-256 and SHA3-256 are written out here: crypto.subtle has no SHA3, * and none of crypto.subtle on a plain http:// LAN address. Ed25519 is@@ -21,6 +24,9 @@"use strict"; const PREFIX = "pyr:", ID_LENGTH = 56, VERSION_ED25519 = 1, REQUEST_TAG = "pyrtan-req-v1";+ const DOC_TAG = "pyrtan-doc-v1\n", HANDOVER_TAG = "pyrtan-handover-v1\n", BACKUP_TAG = "pyrtan-key-backup-v1\n";+ const ABILITIES = ["ask", "chat", "run", "operate"], YEAR = 365 * 24 * 3600, BACKUP_ITERATIONS = 600000, MIN_ITERATIONS = 100000;+ const LOCAL = /^[a-z0-9][a-z0-9._-]{0,62}$/, LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;const enc = new TextEncoder(); const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const concat = (...parts) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };@@ -167,6 +173,108 @@return null; }+ // ------------------------------------------------------------ documents a root signs++ const now = () => Math.floor(Date.now() / 1000);+ const without = (doc, ...keys) => Object.fromEntries(Object.entries(doc).filter(([k]) => !keys.includes(k)));+ async function sign(key, bytes) { return new Uint8Array(await subtle().sign({ name: "Ed25519" }, key.privateKey, bytes)); }+ async function signDocument(key, doc) {+ const body = without(doc, "sig");+ return Object.assign({}, body, { sig: b64url(await sign(key, enc.encode(DOC_TAG + canonical(body)))) });+ }+ const abilities = (may) => { const bad = may.filter((a) => !ABILITIES.includes(a)); if (bad.length || new Set(may).size !== may.length) throw new Error(`may is some of ${ABILITIES.join(", ")}, each once`); return ABILITIES.filter((a) => may.includes(a)); };+ function host(text) {+ const h = String(text || "").toLowerCase(), labels = h.split(".");+ if (h.length > 253 || !labels.every((l) => LABEL.test(l))) throw new Error(`${JSON.stringify(text)} is not a host name`);+ if (labels[labels.length - 1] === "onion" && (labels.length < 2 || labels[labels.length - 2].length !== 56)) throw new Error("an onion address is 56 characters before .onion");+ return h;+ }+ function parseName(text) {+ const t = String(text || "").trim().toLowerCase(), at = t.lastIndexOf("@");+ if (at < 1 || t.startsWith(PREFIX)) throw new Error(`${JSON.stringify(text)} is not a name (name@domain)`);+ const name = t.slice(0, at);+ if (!LOCAL.test(name)) throw new Error(`${JSON.stringify(name)} is not lowercase letters, digits, dot, dash, underscore, at most 63`);+ return { name, domain: host(t.slice(at + 1)) };+ }+ async function makeDevice(root, device, may, opts) {+ const o = opts || {}, issued = o.issued !== undefined ? o.issued : now();+ decode(device);+ if (device.toLowerCase() === root.id) throw new Error("a root does not delegate to itself; it signs for itself");+ return signDocument(root, { "pyrtan-device": 1, root: root.id, device: device.toLowerCase(), label: o.label || "", may: abilities(may), issued, expires: o.expires !== undefined ? o.expires : issued + YEAR });+ }+ async function makeIdentity(root, opts) {+ const o = opts || {};+ return signDocument(root, { "pyrtan-identity": 1, id: root.id, kind: o.kind || "person", routes: o.routes || [], devices: o.devices || [], revoked: o.revoked || [], updated: o.updated !== undefined ? o.updated : now() });+ }+ // A new document, strictly newer than the one it replaces, or a holder would keep the old one.+ const reissue = (doc, root, changes) => makeIdentity(root, Object.assign({ kind: doc.kind, routes: doc.routes, devices: doc.devices, revoked: doc.revoked }, changes, { updated: Math.max(now(), doc.updated + 1) }));+ const withDevice = (doc, root, note) => reissue(doc, root, { devices: doc.devices.filter((n) => n.device !== note.device).concat([note]), revoked: doc.revoked.filter((r) => r !== note.device) });+ const withRevoked = (doc, root, device) => reissue(doc, root, { devices: doc.devices.filter((n) => n.device !== device), revoked: doc.revoked.includes(device) ? doc.revoked : doc.revoked.concat([device]) });+ async function makeName(key, name, opts) {+ const o = opts || {}, n = parseName(name);+ const claim = Object.assign({ "pyrtan-name": 1, name: n.name, domain: n.domain, id: key.id, issued: o.issued !== undefined ? o.issued : now() }, o.extra || {});+ if (o.handoverFrom) claim.handover = { id: o.handoverFrom.id, sig: b64url(await sign(o.handoverFrom, enc.encode(HANDOVER_TAG + canonical(claim)))) };+ return signDocument(key, claim);+ }+ async function makeRelease(key, name, opts) {+ const o = opts || {}, n = parseName(name);+ return signDocument(key, { "pyrtan-name-release": 1, name: n.name, domain: n.domain, id: key.id, issued: o.issued !== undefined ? o.issued : now() });+ }++ // ------------------------------------------------------------ roots and their backups+ //+ // A root made in a page is extractable exactly once: its seed is read out to+ // be encrypted into the backup, then the key is imported again,+ // non-extractable, and the seed is wiped. Restoring decrypts the seed and+ // imports it non-extractable the same way.++ async function createRoot() {+ const pair = await subtle().generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);+ const jwk = await subtle().exportKey("jwk", pair.privateKey);+ const seed = unb64url(jwk.d), publicKey = unb64url(jwk.x);+ return { id: encode(publicKey), seed, publicKey };+ }+ async function backupKey(passphrase, salt, iterations) {+ const base = await subtle().importKey("raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"]);+ return subtle().deriveKey({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);+ }+ async function makeBackup(seed, id, passphrase, opts) {+ const o = opts || {};+ if (String(passphrase).length < 8) throw new Error("a passphrase is at least 8 characters; a long sentence is better");+ const salt = o.salt || crypto.getRandomValues(new Uint8Array(16)), iv = o.iv || crypto.getRandomValues(new Uint8Array(12)), iterations = o.iterations || BACKUP_ITERATIONS;+ const data = new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(BACKUP_TAG + id) }, await backupKey(passphrase, salt, iterations), seed));+ const backup = { "pyrtan-key-backup": 1, id, made: o.made !== undefined ? o.made : now(), kdf: { name: "PBKDF2-SHA256", iterations, salt: b64url(salt) }, cipher: { name: "AES-256-GCM", iv: b64url(iv) }, data: b64url(data) };+ if (o.document) backup.document = o.document;+ return backup;+ }+ function backupProblem(b) {+ if (!b || typeof b !== "object" || b["pyrtan-key-backup"] !== 1) return "not a key backup (pyrtan-key-backup: 1)";+ if (!valid(b.id)) return "the backup names no identity";+ if (!b.kdf || b.kdf.name !== "PBKDF2-SHA256" || !(b.kdf.iterations >= MIN_ITERATIONS && b.kdf.iterations <= 10000000) || typeof b.kdf.salt !== "string") return `the backup's kdf is not PBKDF2-SHA256 with at least ${MIN_ITERATIONS} iterations`;+ if (!b.cipher || b.cipher.name !== "AES-256-GCM" || typeof b.cipher.iv !== "string" || typeof b.data !== "string") return "the backup's cipher is not AES-256-GCM with an iv and data";+ if (b.document && b.document.id !== b.id.toLowerCase()) return "the document beside the key is another identity's";+ return null;+ }+ /** The seed in a backup; throws for a wrong passphrase or a damaged file. */+ async function openBackup(b, passphrase) {+ const bad = backupProblem(b);+ if (bad) throw new Error(bad);+ let seed;+ try {+ seed = new Uint8Array(await subtle().decrypt({ name: "AES-GCM", iv: unb64url(b.cipher.iv), additionalData: enc.encode(BACKUP_TAG + b.id.toLowerCase()) }, await backupKey(passphrase, unb64url(b.kdf.salt), b.kdf.iterations), unb64url(b.data)));+ } catch (e) { throw new Error("the passphrase does not open this backup (or the file is damaged)"); }+ return seed;+ }+ /** A non-extractable key from a seed, checked against the identity it should be. */+ async function rootFromSeed(seed, id) {+ const key = await fromSeedAndPublic(seed, decode(id));+ const probe = enc.encode("pyrtan-probe"), sig = await sign(key, probe);+ const pub = await subtle().importKey("raw", decode(id), { name: "Ed25519" }, false, ["verify"]);+ if (!(await subtle().verify({ name: "Ed25519" }, pub, sig, probe))) throw new Error("the backup's key is not the identity it names");+ seed.fill(0);+ return key;+ }+// ------------------------------------------------------------ the device key, in IndexedDB const store = {@@ -180,5 +288,10 @@async put(v) { const db = await this.open(); return new Promise((res, rej) => { const t = db.transaction("keys", "readwrite"); if (v) t.objectStore("keys").put(v, "device"); else t.objectStore("keys").delete("device"); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); }, };- return { PREFIX, sha256, sha3_256, hex, b64url, unb64url, encode, decode, valid, short, canonical, requestMessage, available, generate, fromSeedAndPublic, signRequest, noteProblem, store };+ return {+ PREFIX, ABILITIES, sha256, sha3_256, hex, b64url, unb64url, encode, decode, valid, short, canonical, parseName, requestMessage,+ available, generate, fromSeedAndPublic, signRequest, noteProblem, store,+ signDocument, makeDevice, makeIdentity, withDevice, withRevoked, makeName, makeRelease,+ createRoot, makeBackup, backupProblem, openBackup, rootFromSeed,+ };}); diff --git a/src/pyrtan/names.py b/src/pyrtan/names.py index 66fff3d..253ce52 100644 --- a/src/pyrtan/names.py +++ b/src/pyrtan/names.py@@ -8,13 +8,20 @@ the first time. A different answer later is never followed; it is shownbeside the pinned one and the person re-pins by hand, as SSH does with a changed host key. That is what makes a domain's word safe to take.-Any domain can be a name provider by serving those files.+Any domain can be a name provider by serving those files, from any+static host or from its own hubs (``pyrtan hub --names DIR``).:func:`build_wellknown` writes them from a directory of claims (and of identity documents, served at ``/.well-known/pyrtan/id/<56>.json``);-pyrtan.com's build runs it on ``site/names/``. :func:`gate` is pyrtan.com's-own rule for that directory: a name is its holder's GitHub login, added-and changed only by the GitHub account whose numeric id the claim-carries, because GitHub frees a login when its owner renames.+pyrtan.com's build runs it on ``site/names/``.++Who may change a name is a directory's own rule. :func:`gate_key`, for any+network on any git host, needs nothing but signatures: a new name is open+to whoever signs it, a name moves to a new key only with a *handover* its+old key signs, it is given up only by a *release* its key signs, and an+identity document is never replaced by an older one or deleted while its+name stands. :func:`gate` is pyrtan.com's own rule: a name is its holder's+GitHub login, added and changed only by the GitHub account whose numeric+id the claim carries, because GitHub frees a login when its owner renames.This module never fetches an ``.onion``: that needs Tor, which pyrtan does not reach yet. The design is ``_meta/identity.md`` §6.@@ -26,6 +33,7 @@ import jsonimport re import shutil import time+from dataclasses import dataclass, fieldfrom pathlib import Path from typing import Any, Callable@@ -140,50 +148,106 @@ class Pins:# ---------------------------------------------------------------- a directory of names-def read_directory(directory: Path, domain: str, *, label: str | None = None) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[Problem]]:- """The claims (``<name>.json``) and identity documents (``<name>.id.json``) in- ``directory``, for ``domain``: ``(claims by name, documents by name, problems)``.- Problems name files as ``label/<file>`` (default: the directory as given)."""- claims: dict[str, dict[str, Any]] = {}- docs: dict[str, dict[str, Any]] = {}- problems: list[Problem] = []- if not directory.is_dir():- return claims, docs, problems+@dataclass+class Directory:+ """What a directory of names holds: claims, identity documents and releases, by name."""+ claims: dict[str, dict[str, Any]] = field(default_factory=dict)+ docs: dict[str, dict[str, Any]] = field(default_factory=dict)+ releases: dict[str, dict[str, Any]] = field(default_factory=dict)+ problems: list[Problem] = field(default_factory=list)+++def load_directory(directory: Path | None, domain: str, *, label: str | None = None) -> Directory:+ """The claims (``<name>.json``), releases (a ``<name>.json`` that gives the name up) and+ identity documents (``<name>.id.json``) in ``directory``, for ``domain``. Problems name+ files as ``label/<file>`` (default: the directory as given); what fails a check is left out."""+ d = Directory()+ if directory is None or not directory.is_dir():+ return dlabel = directory.as_posix() if label is None else label def rel(p: Path) -> str: return f"{label}/{p.name}"+ raw_docs: dict[str, Any] = {}for path in sorted(directory.iterdir()): if path.name.startswith(".") or path.name == "README.md": continue if not path.is_file() or not path.name.endswith(".json"):- problems.append(Problem(rel(path), "only <name>.json and <name>.id.json files belong here"))+ d.problems.append(Problem(rel(path), "only <name>.json and <name>.id.json files belong here"))continue try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e:- problems.append(Problem(rel(path), f"not JSON: {e}"))+ d.problems.append(Problem(rel(path), f"not JSON: {e}"))continue if path.name.endswith(".id.json"):- docs[path.name[: -len(".id.json")]] = data+ raw_docs[path.name[: -len(".id.json")]] = datacontinue stem = path.name[: -len(".json")]- problem = identity.check_name(data, name=f"{stem}@{domain}") if identity.LOCAL.match(stem) else f"{stem!r} is not a name"+ if not identity.LOCAL.match(stem):+ d.problems.append(Problem(rel(path), f"{stem!r} is not a name"))+ continue+ if isinstance(data, dict) and "pyrtan-name-release" in data:+ problem = identity.check_release(data, name=f"{stem}@{domain}")+ target = d.releases+ else:+ problem = identity.check_name(data, name=f"{stem}@{domain}")+ target = d.claimsif problem:- problems.append(Problem(rel(path), problem))+ d.problems.append(Problem(rel(path), problem))continue- claims[stem] = data- for stem, doc in docs.items():+ target[stem] = data+ for stem, doc in raw_docs.items():where = rel(directory / f"{stem}.id.json")- if stem not in claims:- problems.append(Problem(where, f"an identity document needs its name's claim, {stem}.json, beside it"))+ if stem not in d.claims:+ d.problems.append(Problem(where, f"an identity document needs its name's claim, {stem}.json, beside it"))continue- problem = identity.check_identity(doc, ident=claims[stem]["id"])+ problem = identity.check_identity(doc, ident=d.claims[stem]["id"])if problem:- problems.append(Problem(where, problem))- docs = {s: d for s, d in docs.items() if s in claims and identity.check_identity(d, ident=claims[s]["id"]) is None}- return claims, docs, problems+ d.problems.append(Problem(where, problem))+ continue+ d.docs[stem] = doc+ return d+++def read_directory(directory: Path, domain: str, *, label: str | None = None) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[Problem]]:+ """``(claims by name, documents by name, problems)``; see :func:`load_directory`."""+ d = load_directory(directory, domain, label=label)+ return d.claims, d.docs, d.problems+++def gate_key(head: Path, base: Path | None, domain: str, *, label: str = "names") -> list[Problem]:+ """The rule for a directory of names owned by their keys, on any git host: ``head`` is the+ directory a change proposes, ``base`` the one it changes (None for a first commit).+ Needs only the two trees, never who proposed the change."""+ h = load_directory(head, domain, label=label)+ b = load_directory(base, domain, label=label)+ problems = list(h.problems)+ names = sorted(set(h.claims) | set(h.releases) | set(b.claims) | set(b.releases) | set(b.docs))+ for n in names:+ where, dwhere = f"{label}/{n}.json", f"{label}/{n}.id.json"+ held, hc, hr = b.claims.get(n), h.claims.get(n), h.releases.get(n)+ if held is not None:+ if hc is not None and hc["id"] != held["id"]:+ problem = identity.check_handover(hc, held["id"])+ if problem:+ problems.append(Problem(where, problem))+ elif hr is not None and hr["id"] != held["id"]:+ problems.append(Problem(where, f"the release is {identity.short(hr['id'])}'s; {n} is held by {identity.short(held['id'])}"))+ elif hc is None and hr is None and not (head / f"{n}.json").exists():+ problems.append(Problem(where, f"{n} is given up by replacing its claim with a release its key signs (pyrtan id release), not by deleting it"))+ elif hr is not None and n not in b.releases:+ problems.append(Problem(where, f"nobody holds {n}; there is nothing to release"))+ # An identity document only moves forward while its name stays with the same key.+ old = b.docs.get(n)+ if old is not None and held is not None and hc is not None and hc["id"] == held["id"]:+ new = h.docs.get(n)+ if new is None and not (head / f"{n}.id.json").exists():+ problems.append(Problem(dwhere, "an identity document is replaced by a newer one its key signs, not deleted: deleting it would hide its revocations"))+ elif new is not None and new["updated"] < old["updated"]:+ problems.append(Problem(dwhere, "this document is older than the one it replaces: an older one could bring a revoked device back"))+ return problemsdef build_wellknown(directory: Path, out: Path, domain: str) -> tuple[int, list[Problem]]:@@ -276,11 +340,16 @@ def _github_of(raw: bytes | None) -> int | None:def place(checkout: Path, claim: dict[str, Any], doc: dict[str, Any] | None) -> list[Path]:- """Write a claim (and the identity document) into ``checkout/site/names/``."""- directory = checkout / NAMES_DIR- if not (checkout / "site").is_dir():- raise IdentityError(f"{checkout}: no site/ directory; is this a checkout of github.com/b-i-t-8/pyrtan?")- domain = (checkout / "site" / "CNAME").read_text(encoding="utf-8").strip() if (checkout / "site" / "CNAME").is_file() else "pyrtan.com"+ """Write a claim (and the identity document) where ``checkout`` keeps names: a network's+ names repository (``pyrtan-names.json`` and ``names/``), or this repository's ``site/names/``."""+ network = repo_domain(checkout)+ if network is not None:+ directory, domain = checkout / "names", network+ elif (checkout / "site").is_dir():+ directory = checkout / NAMES_DIR+ domain = (checkout / "site" / "CNAME").read_text(encoding="utf-8").strip() if (checkout / "site" / "CNAME").is_file() else "pyrtan.com"+ else:+ raise IdentityError(f"{checkout}: neither a names repository (pyrtan names init) nor a checkout of github.com/b-i-t-8/pyrtan")if claim["domain"] != domain: raise IdentityError(f"this checkout serves names at {domain}; the claim is for {claim['name']}@{claim['domain']}") directory.mkdir(parents=True, exist_ok=True)@@ -302,3 +371,211 @@ def github_id(login: str, *, fetch: Fetch | None = None) -> int:if status != 200 or not isinstance(body, dict) or not isinstance(body.get("id"), int): raise IdentityError(f"{url} answered {status}; pass --github YOUR-NUMERIC-ID") return body["id"]+++# ---------------------------------------------------------------- a network's own names repository++REPO_FILE = "pyrtan-names.json" # at a names repository's root: {"pyrtan-names-repo": 1, "domain": ...}+HOSTS = ("github", "gitlab", "plain")+PYRTAN_GIT = "https://github.com/b-i-t-8/pyrtan"++_README = """# Names at __DOMAIN__++A name here is `<name>@__DOMAIN__`, pointing at a pyrtan identity (a key:+`pyr:` and 56 characters). Anyone resolves it with++ pyrtan id resolve <name>@__DOMAIN__++which fetches `https://__DOMAIN__/.well-known/pyrtan/name/<name>.json`, checks+that the key signed it, and pins the answer. The names are the files in+`names/`; they are served by GitHub or GitLab Pages, or by any hub of the+network started `pyrtan hub --names names --names-domain __DOMAIN__`.++## Getting one++In the pyrtan console, Identity tab: *Claim a name*, with this repository and+`__DOMAIN__`. Or from a shell:++ pyrtan id name <name>@__DOMAIN__ --into <a checkout of this repository>++Then open a pull (or merge) request with `names/<name>.json`, and+`names/<name>.id.json` if you want hubs to find your devices here.++## Who may change a name++Names here are owned by their keys, and the gate checks only signatures:++- a new name is open to whoever signs it, while nobody holds it;+- a name moves to a new key only with a handover its old key signs+ (`pyrtan id name <name>@__DOMAIN__ --handover-from OLD-KEY`);+- a name is given up by replacing its claim with a release its key signs+ (`pyrtan id release <name>@__DOMAIN__`), never by deleting it;+- an identity document is replaced only by a newer one its key signs.++Nothing in this repository is secret: never put an invite, a token or anyone's+key backup here, not even encrypted.+"""++_GITHUB_GATE = """# Generated by `pyrtan names init`: the names gate for __DOMAIN__.+#+# pull_request_target, so the gate is always this file as it is on the default+# branch, and pyrtan comes from a pinned source, never from the pull request: a+# pull request cannot change what judges it. The pull request's tree is checked+# out as data and compared with its merge base; nothing in it is run. Read-only+# token, no secrets, no credentials left in the checkouts, no cache.+# Pin __REF__ to a commit of pyrtan you have read.++name: names-gate++on:+ pull_request_target:+ paths: ["names/**"]++permissions:+ contents: read++jobs:+ gate:+ runs-on: ubuntu-latest+ env:+ BASE_SHA: ${{ github.event.pull_request.base.sha }}+ steps:+ - uses: actions/checkout@v4+ with:+ ref: ${{ github.event.pull_request.base.sha }}+ path: base-repo+ fetch-depth: 0+ persist-credentials: false+ - uses: actions/checkout@v4+ with:+ repository: ${{ github.event.pull_request.head.repo.full_name }}+ ref: ${{ github.event.pull_request.head.sha }}+ path: pr+ fetch-depth: 0+ persist-credentials: false+ - name: The merge base, as data+ run: |+ git -C pr fetch --no-tags "$GITHUB_WORKSPACE/base-repo" "$BASE_SHA"+ git -C pr worktree add --detach "$GITHUB_WORKSPACE/base" "$(git -C pr merge-base "$BASE_SHA" HEAD)"+ - uses: actions/setup-python@v5+ with:+ python-version: "3.12"+ - run: pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"+ - run: pyrtan names gate pr/names --base base/names --domain __DOMAIN__+"""++_GITHUB_PAGES = """# Generated by `pyrtan names init`: serve names/ as https://__DOMAIN__/.well-known/pyrtan/.+# Set the repository's Pages source to "GitHub Actions" and its custom domain to __DOMAIN__.++name: names-pages++on:+ push:+ branches: [main]+ workflow_dispatch:++permissions:+ contents: read+ pages: write+ id-token: write++concurrency:+ group: pages+ cancel-in-progress: true++jobs:+ build:+ runs-on: ubuntu-latest+ steps:+ - uses: actions/checkout@v4+ - uses: actions/setup-python@v5+ with:+ python-version: "3.12"+ - run: pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"+ - run: pyrtan id wellknown names --domain __DOMAIN__ --out site && echo __DOMAIN__ > site/CNAME+ # v3, not v4: v4 leaves out dot-directories, and the names live in .well-known/.+ - uses: actions/upload-pages-artifact@v3+ with:+ path: site++ deploy:+ needs: build+ runs-on: ubuntu-latest+ environment:+ name: github-pages+ url: ${{ steps.deployment.outputs.page_url }}+ steps:+ - id: deployment+ uses: actions/deploy-pages@v4+"""++_GITLAB = """# Generated by `pyrtan names init`: the names gate for __DOMAIN__, and Pages.+#+# On GitLab a merge request's pipeline runs the merge request's own copy of this+# file, so read any change to it before merging: the gate is pyrtan from a pinned+# source, not from the merge request, but this file says which. Protect the+# default branch and require the pipeline to pass. Pin __REF__ to a commit of+# pyrtan you have read.++variables:+ GIT_DEPTH: "0"++names-gate:+ image: python:3.12+ rules:+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"+ changes: ["names/**/*"]+ script:+ - pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"+ - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"+ - git worktree add --detach /tmp/base "$(git merge-base FETCH_HEAD HEAD)"+ - pyrtan names gate names --base /tmp/base/names --domain __DOMAIN__++pages:+ image: python:3.12+ rules:+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH+ script:+ - pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__"+ - pyrtan id wellknown names --domain __DOMAIN__ --out public+ artifacts:+ paths: [public]+"""+++def init_repo(directory: Path, domain: str, *, host: str = "github", pyrtan_ref: str = "main") -> tuple[list[Path], list[Path]]:+ """Write a names repository for ``domain`` into ``directory``: ``pyrtan-names.json``, a+ README, ``names/``, and the gate and Pages for ``host``. Returns ``(written, kept)``: a file+ that is already there is kept, never overwritten."""+ domain = identity.check_host(domain.lower())+ if host not in HOSTS:+ raise IdentityError(f"host is one of {', '.join(HOSTS)}")+ if not re.fullmatch(r"[A-Za-z0-9._/-]{1,80}", pyrtan_ref):+ raise IdentityError(f"{pyrtan_ref!r} is not a git ref")+ fill = lambda t: t.replace("__DOMAIN__", domain).replace("__REF__", pyrtan_ref).replace("__PYRTAN__", PYRTAN_GIT) # noqa: E731+ files = {REPO_FILE: json.dumps({"pyrtan-names-repo": 1, "domain": domain}, indent=2) + "\n", "README.md": fill(_README), "names/.gitkeep": ""}+ if host == "github":+ files[".github/workflows/names-gate.yml"] = fill(_GITHUB_GATE)+ files[".github/workflows/names-pages.yml"] = fill(_GITHUB_PAGES)+ elif host == "gitlab":+ files[".gitlab-ci.yml"] = fill(_GITLAB)+ written, kept = [], []+ for rel, text in files.items():+ path = directory / rel+ if path.exists():+ kept.append(path)+ continue+ path.parent.mkdir(parents=True, exist_ok=True)+ path.write_text(text, encoding="utf-8")+ written.append(path)+ return written, kept+++def repo_domain(checkout: Path) -> str | None:+ """The domain a names repository (``pyrtan-names.json``) is for, if ``checkout`` is one."""+ try:+ data = json.loads((checkout / REPO_FILE).read_text(encoding="utf-8"))+ except (OSError, json.JSONDecodeError):+ return None+ return data.get("domain") if isinstance(data, dict) and data.get("pyrtan-names-repo") == 1 and isinstance(data.get("domain"), str) else None+diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 58894d3..faa5fec 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -24,11 +24,18 @@ bearer; the hub must allow the console's origin with ``--cors``.Forgetting the hub makes the browser its own hub again. No external assets, no build step.-On a hub of 0.14 or later its Identity tab makes a key for this browser-(``identity.js``, inlined: WebCrypto's Ed25519, non-extractable, kept in-IndexedDB) and takes the device note ``pyrtan id device add`` prints;-from then on it signs what it sends the hub with that key, and a member-joined from here follows as it. A page served over plain ``http://`` to+Its Identity tab does all of ``pyrtan id`` without a shell+(``identity.js``, inlined: WebCrypto's Ed25519, PBKDF2 and AES-GCM). It+makes your identity in this browser, hands you its backup at once+(encrypted with your passphrase, to download or keep in your own private+repository; the page then holds the key non-extractable), restores one,+claims a name (pyrtan.com's, through GitHub's own new-file page, which+opens the pull request from your account; or any network's names+repository), and adds and revokes devices. A browser can instead be a+device of an identity held elsewhere: it makes its own key and takes the+note its root signs. On a hub of 0.14 or later the key signs what the+console sends, and a member joined from here follows as it; the Access+tab keeps the hub's grants. A page served over plain ``http://`` toanything but localhost has no WebCrypto, holds no key, and says so. Its Agents tab starts an agent beside the swarm (``agents.js``, inlined;@@ -89,6 +96,8 @@ PAGE = r"""<!doctype html>form.row { display:flex; gap:8px; margin:0 0 12px; flex-wrap:wrap } div.row { display:flex; gap:8px; flex-wrap:wrap } form.row input[type=text], form.row input[type=url], form.row input[type=password], div.row input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }+ [hidden] { display:none !important } /* even where an inline style sets display */+ #tab-identity input[type=text], #tab-identity input[type=password], #tab-identity input[type=file], #tab-access input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }button { font:inherit; padding:10px 16px; border:0; border-radius:8px; background:var(--accent); color:var(--accent-fg); cursor:pointer } button.quiet { background:var(--soft); color:var(--fg) } button:disabled { opacity:.6; cursor:wait }@@ -167,6 +176,7 @@ PAGE = r"""<!doctype html><a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a> <a href="#member" data-tab="member" id="member-tab" hidden>Member</a> <a href="#identity" data-tab="identity" id="identity-tab" hidden>Identity</a>+ <a href="#access" data-tab="access" id="access-tab" hidden>Access</a><a href="#chat" data-tab="chat" id="chat-tab" hidden>Chat</a> <a href="#api" data-tab="api">API</a> </nav>@@ -319,26 +329,98 @@ PAGE = r"""<!doctype html></section> <section id="tab-identity" hidden>- <h2>This browser's key</h2>- <p class="mute" style="font-size:14px;margin-top:0">A key made here, kept here (the browser holds it and will not hand it over, not even to this page), and used to sign what this console sends the hub. Pair it with your identity and the hub gives it what it grants you, capped by what the note lets this browser do; or grant the key itself, <code>pyrtan allow pyr:… --may chat</code>. A member joined from here follows as this key.</p>- <div class="card">- <div id="ident-none" class="notice" hidden>This page cannot hold a key: it needs a secure page (https, or localhost) in a browser with Ed25519 in WebCrypto. It still asks, chats and joins, without a key: with the hub's token, or as an anonymous member.</div>- <div id="ident-off" hidden>- <button type="button" id="ident-make">Make a key for this browser</button>+ <h2>Your identity</h2>+ <p class="mute" style="font-size:14px;margin-top:0">An identity is a key, made here and held by this browser, which will not hand it over, not even to this page. No account anywhere: hubs grant your key what they choose, a name like <code>you@pyrtan.com</code> points at it, and your other devices speak for it with a note it signs. Lose the key and its backup and nobody can give it back.</p>+ <div id="ident-none" class="notice" hidden>This page cannot hold a key: it needs a secure page (https, or localhost) in a browser with Ed25519 in WebCrypto. It still asks, chats and joins, without a key: with the hub's token, or as an anonymous member.</div>++ <div class="card" id="ident-start" hidden>+ <div style="display:flex;gap:8px;flex-wrap:wrap">+ <button type="button" id="ident-new">Make my identity</button>+ <button type="button" class="quiet" id="ident-restore-open">Restore from a backup</button>+ <button type="button" class="quiet" id="ident-make">Use this browser as a device of an identity I have</button></div>- <div id="ident-on" hidden>- <div><span class="status done">key</span> <code id="ident-id"></code></div>- <div class="mute" style="font-size:14px;margin:6px 0" id="ident-paired"></div>- <form id="ident-note-form" style="margin:10px 0">- <div class="mute" style="font-size:14px;margin-bottom:6px">On the machine with your identity: <code id="ident-howto"></code>, then paste what it prints here.</div>- <div class="row"><input type="text" id="ident-note" placeholder='{"pyrtan-device": 1, "root": "pyr:…", ...}' autocomplete="off"><button type="submit">Use this note</button></div>- </form>- <div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">- <label><input type="checkbox" id="ident-sign"> sign requests to this hub with it</label>- <button type="button" class="quiet" id="ident-forget">Forget this key</button>+ <form id="ident-new-form" style="margin-top:12px" hidden>+ <div class="mute" style="font-size:14px;margin-bottom:6px">Your key is made here, then sealed with a passphrase into a backup you keep. Choose a long sentence you will remember: nobody can reset it, and the backup is only as strong as it is.</div>+ <div class="row"><input type="password" id="ident-pass" placeholder="passphrase (a long sentence)" autocomplete="new-password"><input type="password" id="ident-pass2" placeholder="the same again" autocomplete="new-password"><button type="submit">Make it</button></div>+ </form>+ <form id="ident-restore-form" style="margin-top:12px" hidden>+ <div class="mute" style="font-size:14px;margin-bottom:6px">The backup file this console or <code>pyrtan id backup</code> wrote, and its passphrase.</div>+ <div class="row"><input type="file" id="ident-file" accept="application/json,.json"><input type="password" id="ident-restore-pass" placeholder="passphrase" autocomplete="current-password"><button type="submit">Restore</button></div>+ </form>+ </div>++ <div class="card" id="ident-root" hidden>+ <div><span class="status done">identity</span> <code id="root-id"></code></div>+ <div id="root-backup-state" style="margin:8px 0"></div>+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">+ <button type="button" id="root-download">Download my backup</button>+ <input type="text" id="root-gh" placeholder="your GitHub login" autocomplete="off" style="max-width:220px">+ <button type="button" class="quiet" id="root-gh-go">Keep it in a private GitHub repository</button>+ </div>+ <div id="root-gh-out" class="mute" style="font-size:14px;margin-top:8px"></div>+ </div>++ <div class="card" id="ident-claim" hidden>+ <h2 style="font-size:15px;margin:0 0 8px">Claim a name</h2>+ <form id="claim-form">+ <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px;font-size:14px">+ <label><input type="radio" name="claim-where" value="pyrtan" checked> at pyrtan.com (your GitHub login)</label>+ <label><input type="radio" name="claim-where" value="network"> at another network</label>+ </div>+ <div class="row" id="claim-pyrtan"><input type="text" id="claim-login" placeholder="your GitHub login" autocomplete="off"><button type="submit">Make my claim</button></div>+ <div id="claim-network" hidden>+ <div class="row"><input type="text" id="claim-name" placeholder="name@network-domain, e.g. alice@names.example.org" autocomplete="off"></div>+ <div class="row" style="margin-top:6px"><input type="text" id="claim-repo" placeholder="its names repository, e.g. https://github.com/example/names" autocomplete="off"><input type="text" id="claim-branch" value="main" style="max-width:120px" autocomplete="off"><button type="submit">Make my claim</button></div></div>+ </form>+ <div id="claim-out"></div>+ </div>++ <div class="card" id="ident-devices" hidden>+ <h2 style="font-size:15px;margin:0 0 8px">Devices that speak for you</h2>+ <table id="dev-table"><thead><tr><th>Device</th><th>May</th><th>Until</th><th></th></tr></thead><tbody></tbody></table>+ <form id="dev-form" style="margin-top:10px">+ <div class="mute" style="font-size:14px;margin-bottom:6px">On the other device, open this console's Identity tab, choose <em>Use this browser as a device</em>, and paste its code here.</div>+ <div class="row"><input type="text" id="dev-code" placeholder="its code, pyr:…" autocomplete="off"><input type="text" id="dev-label" placeholder="what to call it: phone" style="max-width:200px" autocomplete="off"></div>+ <div style="display:flex;gap:14px;flex-wrap:wrap;margin:8px 0;font-size:14px;align-items:center" id="dev-may"><span class="mute">it may</span>+ <label><input type="checkbox" value="ask" checked> ask</label><label><input type="checkbox" value="chat" checked> chat</label><label><input type="checkbox" value="run"> run trees</label><label><input type="checkbox" value="operate"> operate hubs</label>+ <button type="submit">Sign its note</button></div>+ </form>+ <div id="dev-out"></div>+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">+ <button type="button" class="quiet" id="doc-push" hidden>Give this hub my newest document</button>+ <button type="button" class="quiet" id="doc-download">Download my identity document</button></div> </div>++ <div class="card" id="ident-on" hidden>+ <div><span class="status done">device</span> <code id="ident-id"></code> <button type="button" class="quiet" id="ident-copy">Copy its code</button></div>+ <div class="mute" style="font-size:14px;margin:6px 0" id="ident-paired"></div>+ <form id="ident-note-form" style="margin:10px 0">+ <div class="mute" style="font-size:14px;margin-bottom:6px">Give this code to the browser that holds your identity (its Identity tab, <em>Devices</em>), or run <code id="ident-howto"></code>; then paste the note it gives back here.</div>+ <div class="row"><input type="text" id="ident-note" placeholder='{"pyrtan-device": 1, "root": "pyr:…", ...}' autocomplete="off"><button type="submit">Use this note</button></div>+ </form>+ </div>++ <div id="ident-common" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:8px" hidden>+ <label><input type="checkbox" id="ident-sign"> sign requests to the hub with this key</label>+ <button type="button" class="quiet" id="ident-forget">Forget it on this browser</button>+ </div>+ </section>++ <section id="tab-access" hidden>+ <h2>Who may do what here</h2>+ <p class="mute" style="font-size:14px;margin-top:0">The identities this hub grants anything, instead of handing out its token: <em>ask</em> (the trees), <em>chat</em> (the lobby, bots included), <em>run</em> (be a trusted member, running its trees) and <em>operate</em> (everything the token allows). A device gets what its identity is granted, capped by its note. A name is looked up once and pinned.</p>+ <div id="access-note" class="notice" hidden></div>+ <div class="card">+ <table id="grant-table"><thead><tr><th>Who</th><th>May</th><th>Document</th><th></th></tr></thead><tbody></tbody></table>+ <form id="grant-form" style="margin-top:10px">+ <div class="row"><input type="text" id="grant-who" placeholder="name@domain or pyr:…" autocomplete="off"><button type="submit">Grant</button></div>+ <div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:14px" id="grant-may">+ <label><input type="checkbox" value="ask" checked> ask</label><label><input type="checkbox" value="chat"> chat</label><label><input type="checkbox" value="run"> run</label><label><input type="checkbox" value="operate"> operate</label>+ </div>+ </form>+ </div></section> <section id="tab-chat" hidden>@@ -440,6 +522,17 @@ function problem(msg, bad) { $("#problem").innerHTML = msg ? `<div class="notice// This browser's key: made and held by crypto.subtle (non-extractable, in // IndexedDB), paired with a root by a device note, used to sign requests // for the hub's audience (named at /api). See identity.js.+// pyrtan.com's names: files in this repository, added by pull request (site/names/README.md).+const PYRTAN_NAMES = {repo: "b-i-t-8/pyrtan", branch: "main", dir: "site/names", domain: "pyrtan.com"};+const githubNewFile = (repo, branch, dir, filename, text) => `https://github.com/${repo}/new/${encodeURIComponent(branch)}${dir ? "/" + dir : ""}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(text)}`;+const githubRepo = (url) => { const m = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(String(url || "").trim()); return m ? m[1] : null; };+function saveFile(name, data) {+ const a = document.createElement("a");+ a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2) + "\n"], {type: "application/json"}));+ a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);+}+const pre = (data) => `<pre style="max-height:180px">${esc(JSON.stringify(data, null, 2))}</pre>`;+const ident = { ok: null, key: null, audience: null, sign: true, async init() {@@ -448,47 +541,207 @@ const ident = {try { this.sign = localStorage.getItem("pyrtan.identity.sign") !== "off"; } catch (e) {} this.render(); },+ isRoot() { return !!(this.key && this.key.role === "root"); },active() { return !!(this.ok && this.key && this.sign && this.audience); }, async headers(method, path, body) { if (!this.active()) return {}; return PyrtanIdentity.signRequest(this.key, this.audience, method, path, body, this.key.note || null); },+ async keep() { await PyrtanIdentity.store.put(this.key); this.render(); },+ // ---- a device of an identity held elsewhereasync make() { const k = await PyrtanIdentity.generate();- this.key = {id: k.id, privateKey: k.privateKey, publicKey: k.publicKey, note: null, made: Date.now()};- await PyrtanIdentity.store.put(this.key);- this.render();+ this.key = {role: "device", id: k.id, privateKey: k.privateKey, publicKey: k.publicKey, note: null, made: Date.now()};+ await this.keep();}, async useNote(text) {- let note; try { note = JSON.parse(text); } catch (e) { problem("The note is JSON: paste the line pyrtan id device add printed.", true); return; }+ let note; try { note = JSON.parse(text); } catch (e) { problem("The note is JSON: paste what the browser holding your identity, or pyrtan id device add, gave you.", true); return; }const bad = PyrtanIdentity.noteProblem(note, this.key.id); if (bad) { problem(bad, true); return; }- this.key.note = note; await PyrtanIdentity.store.put(this.key); problem(""); this.render();+ this.key.note = note; problem(""); await this.keep();+ },+ // ---- your identity, in this browser+ async makeRoot(pass) {+ const r = await PyrtanIdentity.createRoot();+ const sealed = await PyrtanIdentity.makeBackup(r.seed, r.id, pass);+ const key = await PyrtanIdentity.rootFromSeed(r.seed, r.id); // non-extractable from here on; the seed is wiped+ const document = await PyrtanIdentity.makeIdentity(key);+ this.key = {role: "root", id: key.id, privateKey: key.privateKey, sealed, document, backedUp: false, note: null, made: Date.now()};+ await this.keep();+ },+ async restore(file, pass) {+ let b; try { b = JSON.parse(await file.text()); } catch (e) { throw new Error("that file is not a backup"); }+ const seed = await PyrtanIdentity.openBackup(b, pass);+ const key = await PyrtanIdentity.rootFromSeed(seed, b.id.toLowerCase());+ const sealed = Object.fromEntries(Object.entries(b).filter(([k]) => k !== "document"));+ const document = b.document || await PyrtanIdentity.makeIdentity(key);+ this.key = {role: "root", id: key.id, privateKey: key.privateKey, sealed, document, backedUp: true, note: null, made: Date.now()};+ await this.keep();+ },+ backup() { return Object.assign({}, this.key.sealed, {document: this.key.document}); },+ async backedUp() { if (!this.key.backedUp) { this.key.backedUp = true; await this.keep(); } },+ async addDevice(code, may, label) {+ const note = await PyrtanIdentity.makeDevice(this.key, code.trim(), may, {label});+ this.key.document = await PyrtanIdentity.withDevice(this.key.document, this.key, note);+ await this.keep();+ return note;+ },+ async revoke(device) {+ this.key.document = await PyrtanIdentity.withRevoked(this.key.document, this.key, device);+ await this.keep();}, async forget() {- if (!confirm("Forget this browser's key? A hub that granted it, or a member that joined as it, will not know this browser again.")) return;+ const what = this.isRoot() ? "Forget your identity on this browser? Without its backup nobody can give it back." : "Forget this browser's key? A hub that granted it, or a member that joined as it, will not know this browser again.";+ if (!confirm(what)) return;await PyrtanIdentity.store.put(null); this.key = null; this.render(); }, render() { if (!$("#tab-identity")) return;+ const root = this.isRoot(), device = !!this.key && !root;$("#ident-none").hidden = this.ok !== false;- $("#ident-off").hidden = !this.ok || !!this.key;- $("#ident-on").hidden = !this.ok || !this.key;+ $("#ident-start").hidden = !this.ok || !!this.key;+ for (const id of ["#ident-root", "#ident-claim", "#ident-devices"]) $(id).hidden = !this.ok || !root;+ $("#ident-on").hidden = !this.ok || !device;+ $("#ident-common").hidden = !this.ok || !this.key;if (!this.key) return;- $("#ident-id").textContent = this.key.id;- const n = this.key.note;- $("#ident-paired").innerHTML = n- ? `speaks for <code>${esc(n.root)}</code> · may ${esc(n.may.join(", ") || "nothing")} · until ${esc(new Date(n.expires * 1000).toLocaleDateString())}${n.label ? " · " + esc(n.label) : ""}`- : "not paired: it signs as itself, so a hub grants this key, or nothing";- $("#ident-howto").textContent = `pyrtan id device add ${this.key.id} --may ask,chat,run --label browser`;$("#ident-sign").checked = this.sign;+ $("#doc-push").hidden = !root || !this.audience;+ if (device) {+ $("#ident-id").textContent = this.key.id;+ const n = this.key.note;+ $("#ident-paired").innerHTML = n+ ? `speaks for <code>${esc(n.root)}</code> · may ${esc(n.may.join(", ") || "nothing")} · until ${esc(new Date(n.expires * 1000).toLocaleDateString())}${n.label ? " · " + esc(n.label) : ""}`+ : "not paired yet: it signs as itself, so a hub grants this key, or nothing";+ $("#ident-howto").textContent = `pyrtan id device add ${this.key.id} --may ask,chat --label browser`;+ return;+ }+ $("#root-id").textContent = this.key.id;+ $("#root-backup-state").innerHTML = this.key.backedUp+ ? `<span class="mute" style="font-size:14px">Backed up. Keep the file somewhere only you can read; never in a public repository.</span>`+ : `<div class="notice bad" style="margin:0">Save your backup now. It is the only way back to this identity if this browser's data is cleared. It is encrypted with your passphrase; keep it where only you can read it.</div>`;+ const tb = $("#dev-table tbody"); tb.innerHTML = "";+ const doc = this.key.document;+ for (const n of doc.devices) tb.insertAdjacentHTML("beforeend", `<tr><td>${esc(n.label || "-")}<div class="mute"><code>${esc(PyrtanIdentity.short(n.device))}</code></div></td><td>${esc(n.may.join(", "))}</td><td class="mute">${esc(new Date(n.expires * 1000).toLocaleDateString())}</td><td><button type="button" class="quiet" data-revoke="${esc(n.device)}">Revoke</button></td></tr>`);+ for (const r of doc.revoked) tb.insertAdjacentHTML("beforeend", `<tr><td class="mute"><code>${esc(PyrtanIdentity.short(r))}</code></td><td class="mute" colspan="3">revoked</td></tr>`);+ if (!doc.devices.length && !doc.revoked.length) tb.innerHTML = `<tr><td class="mute" colspan="4">none yet</td></tr>`;}, };++// ---- starting$("#ident-make").addEventListener("click", () => ident.make().catch((e) => problem("Could not make a key: " + e.message, true)));+$("#ident-new").addEventListener("click", () => { $("#ident-new-form").hidden = false; $("#ident-restore-form").hidden = true; $("#ident-pass").focus(); });+$("#ident-restore-open").addEventListener("click", () => { $("#ident-restore-form").hidden = false; $("#ident-new-form").hidden = true; });+$("#ident-new-form").addEventListener("submit", async (e) => {+ e.preventDefault();+ const a = $("#ident-pass").value, b = $("#ident-pass2").value;+ if (a !== b) { problem("The two passphrases differ.", true); return; }+ if (a.length < 8) { problem("A passphrase is at least 8 characters; a long sentence is better.", true); return; }+ const btn = e.submitter || e.target.querySelector("button"); btn.disabled = true; btn.textContent = "Making it…";+ try { await ident.makeRoot(a); problem(""); } catch (err) { problem("Could not make your identity: " + err.message, true); }+ btn.disabled = false; btn.textContent = "Make it"; $("#ident-pass").value = $("#ident-pass2").value = "";+});+$("#ident-restore-form").addEventListener("submit", async (e) => {+ e.preventDefault();+ const f = $("#ident-file").files[0]; if (!f) { problem("Choose the backup file.", true); return; }+ try { await ident.restore(f, $("#ident-restore-pass").value); problem(""); } catch (err) { problem(err.message, true); }+ $("#ident-restore-pass").value = "";+});+// ---- a device$("#ident-note-form").addEventListener("submit", (e) => { e.preventDefault(); ident.useNote($("#ident-note").value.trim()); });+$("#ident-copy").addEventListener("click", () => { navigator.clipboard && navigator.clipboard.writeText(ident.key.id); });+// ---- backup+$("#root-download").addEventListener("click", async () => { saveFile("pyrtan-identity-backup.json", ident.backup()); await ident.backedUp(); });+$("#root-gh-go").addEventListener("click", async () => {+ const login = $("#root-gh").value.trim();+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(login)) { problem("Enter your GitHub login.", true); return; }+ const create = "https://github.com/new?name=pyrtan-identity&visibility=private&description=" + encodeURIComponent("My pyrtan identity backup (encrypted). Keep this repository private.");+ const file = githubNewFile(`${login}/pyrtan-identity`, "main", "", "pyrtan-identity-backup.json", JSON.stringify(ident.backup(), null, 2) + "\n");+ $("#root-gh-out").innerHTML = `1. <a href="${esc(create)}" target="_blank" rel="noopener">Make a <strong>private</strong> repository named pyrtan-identity</a> (check that it says Private). 2. <a href="${esc(file)}" target="_blank" rel="noopener" id="root-gh-file">Commit your backup to it</a>. GitHub keeps every version; the backup is only as strong as your passphrase, so never make that repository public.`;+ $("#root-gh-file").addEventListener("click", () => ident.backedUp());+});+// ---- claiming a name+for (const r of document.querySelectorAll('input[name="claim-where"]')) r.addEventListener("change", () => { const net = document.querySelector('input[name="claim-where"]:checked').value === "network"; $("#claim-pyrtan").hidden = net; $("#claim-network").hidden = !net; });+$("#claim-form").addEventListener("submit", async (e) => {+ e.preventDefault();+ const out = $("#claim-out"); out.innerHTML = "";+ try {+ let claim, repo, branch, dir, gh = null;+ if (document.querySelector('input[name="claim-where"]:checked').value === "pyrtan") {+ const login = $("#claim-login").value.trim().toLowerCase();+ if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(login)) throw new Error("enter your GitHub login");+ const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {headers: {"Accept": "application/json"}});+ if (!r.ok) throw new Error(`GitHub does not know ${login} (${r.status})`);+ gh = (await r.json()).id;+ claim = await PyrtanIdentity.makeName(ident.key, `${login}@${PYRTAN_NAMES.domain}`, {extra: {github: gh}});+ ({repo, branch, dir} = PYRTAN_NAMES);+ } else {+ claim = await PyrtanIdentity.makeName(ident.key, $("#claim-name").value);+ repo = githubRepo($("#claim-repo").value); branch = $("#claim-branch").value.trim() || "main"; dir = "names";+ if (!repo && $("#claim-repo").value.trim()) repo = null;+ }+ const file = `${claim.name}.json`, docFile = `${claim.name}.id.json`, text = JSON.stringify(claim, null, 2) + "\n", docText = JSON.stringify(ident.key.document, null, 2) + "\n";+ let html = `<div style="margin-top:10px">Your claim to <code>${esc(claim.name)}@${esc(claim.domain)}</code>, signed by your key:</div>${pre(claim)}`;+ if (repo) {+ html += `<div>1. <a href="${esc(githubNewFile(repo, branch, dir, file, text))}" target="_blank" rel="noopener" id="claim-open">Propose it on GitHub</a>: GitHub opens <code>${esc(dir)}/${esc(file)}</code> filled in; choose <em>Propose changes</em>, then <em>Create pull request</em>${gh !== null ? `, from the GitHub account ${esc(claim.name)}` : ""}.</div>`;+ html += `<div>2. Optional, once that is merged: <a href="${esc(githubNewFile(repo, branch, dir, docFile, docText))}" target="_blank" rel="noopener">propose your identity document</a> (<code>${esc(docFile)}</code>), so hubs find your devices there.</div>`;+ } else {+ html += `<div>Add it to the network's names repository as <code>names/${esc(file)}</code> (and your identity document as <code>names/${esc(docFile)}</code>), and open a merge request there.</div>`;+ }+ html += `<div style="margin-top:6px"><button type="button" class="quiet" id="claim-save">Download the claim</button> <button type="button" class="quiet" id="claim-copy">Copy it</button></div>`;+ out.innerHTML = html;+ $("#claim-save").addEventListener("click", () => saveFile(file, claim));+ $("#claim-copy").addEventListener("click", () => navigator.clipboard && navigator.clipboard.writeText(text));+ problem("");+ } catch (err) { problem("Could not make the claim: " + err.message, true); }+});+// ---- devices+$("#dev-form").addEventListener("submit", async (e) => {+ e.preventDefault();+ const may = Array.from(document.querySelectorAll("#dev-may input:checked")).map((i) => i.value);+ try {+ const note = await ident.addDevice($("#dev-code").value, may, $("#dev-label").value.trim());+ $("#dev-out").innerHTML = `<div style="margin-top:10px">Paste this on <code>${esc(note.label || PyrtanIdentity.short(note.device))}</code>, under <em>Use this note</em>:</div>${pre(note)}<button type="button" class="quiet" id="dev-copy">Copy it</button>`;+ $("#dev-copy").addEventListener("click", () => navigator.clipboard && navigator.clipboard.writeText(JSON.stringify(note)));+ $("#dev-code").value = $("#dev-label").value = ""; problem("");+ } catch (err) { problem("Could not sign the note: " + err.message, true); }+});+$("#dev-table").addEventListener("click", async (e) => {+ const b = e.target.closest("button[data-revoke]"); if (!b) return;+ if (!confirm("Revoke this device? Hubs refuse it once they have your newer document.")) return;+ await ident.revoke(b.dataset.revoke);+ problem("Revoked. Give your newest document to the hubs you use, and to your name's repository, so they know now.");+});+$("#doc-push").addEventListener("click", async () => {+ const r = await api("identity", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({document: ident.key.document})});+ const b = await r.json().catch(() => ({}));+ problem(r.ok ? "This hub holds your newest document." : `The hub did not take it: ${b.say || r.status}`, !r.ok);+});+$("#doc-download").addEventListener("click", () => saveFile(`${ident.key.id.slice(4, 12)}.id.json`, ident.key.document));$("#ident-forget").addEventListener("click", () => ident.forget()); $("#ident-sign").addEventListener("change", (e) => { ident.sign = e.target.checked; try { localStorage.setItem("pyrtan.identity.sign", ident.sign ? "on" : "off"); } catch (err) {} });+// ---------------------------------------------------------------- access: the hub's grants+async function loadGrants() {+ const tb = $("#grant-table tbody"), note = $("#access-note");+ let r; try { r = await api("allow"); } catch (e) { note.hidden = false; note.textContent = String(e); return; }+ if (r.status === 401 || r.status === 403) { note.hidden = false; note.textContent = "Only this hub's operator sees and changes its grants: connect with its token, or as an identity it grants operate."; tb.innerHTML = ""; return; }+ note.hidden = true;+ const body = await r.json().catch(() => ({grants: []}));+ tb.innerHTML = (body.grants || []).map((g) => `<tr><td>${g.name ? `<code>${esc(g.name)}</code>` : ""}<div class="mute"><code>${esc(PyrtanIdentity.short(g.id))}</code></div></td><td>${esc(g.may.join(", "))}</td><td class="mute">${g.document ? `${esc(String(g.document.devices))} device(s), ${esc(String(g.document.revoked))} revoked` : "none yet"}</td><td><button type="button" class="quiet" data-disallow="${esc(g.id)}">Remove</button></td></tr>`).join("") || `<tr><td class="mute" colspan="4">nobody yet</td></tr>`;+}+async function grantPost(path, payload) {+ const r = await api(path, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload)});+ const b = await r.json().catch(() => ({}));+ if (!r.ok) problem(b.say || `the hub answered ${r.status}`, true); else problem("");+ loadGrants();+}+$("#grant-form").addEventListener("submit", (e) => {+ e.preventDefault();+ const may = Array.from(document.querySelectorAll("#grant-may input:checked")).map((i) => i.value);+ grantPost("allow", {who: $("#grant-who").value.trim(), may}).then(() => { $("#grant-who").value = ""; });+});+$("#grant-table").addEventListener("click", (e) => { const b = e.target.closest("button[data-disallow]"); if (b) grantPost("disallow", {who: b.dataset.disallow}); });+function showTab(name) { for (const a of document.querySelectorAll("#tabs a")) { if (a.dataset.tab === name) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); } for (const s of document.querySelectorAll("main > section[id^=tab-]")) s.hidden = s.id !== "tab-" + name;@@ -499,6 +752,7 @@ function showTab(name) {if (name === "directory") loadDirectory(); if (name === "member") member.render(); if (name === "identity") ident.render();+ if (name === "access") loadGrants();if (name === "chat") chat.count(); if (name === "agents") agentsUI.render(); }@@ -620,7 +874,8 @@ async function load() {$("#directory-tab").hidden = !(isHub && a.federation); $("#member-tab").hidden = !(isHub && a.federation); ident.audience = a.audience || null;- $("#identity-tab").hidden = !(isHub && a.audience);+ $("#identity-tab").hidden = false; // an identity needs no hub; signing does, when there is one+ $("#access-tab").hidden = !(isHub && a.audience);$("#chat-tab").hidden = !(isHub && a.chat); if (a.chat && a.chat_bots === false && token) { try { a.chat_bots = (await json("api")).chat_bots; } catch (e) {} // the first ask went without the token diff --git a/tests/browser/console_gui.cjs b/tests/browser/console_gui.cjs new file mode 100644 index 0000000..344f1d5 --- /dev/null +++ b/tests/browser/console_gui.cjs@@ -0,0 +1,124 @@+// The console's identity, end to end, in Chromium against a running hub, with no shell:+// node tests/browser/console_gui.cjs HUB_URL DOWNLOAD_DIR+// Three browser contexts are three devices. A makes an identity and saves its+// backup, claims names, signs B's note and keeps the hub's grants; B is a device+// that asks; C restores A from the backup. Prints one line per step (WORD rest);+// after `ROOT <id>` it waits for a line on stdin, while the test grants that root.+const { chromium } = require("playwright");+const readline = require("readline");+const path = require("path");+const fs = require("fs");+const [hub, downloads] = process.argv.slice(2);+const rl = readline.createInterface({ input: process.stdin });+const lines = []; let waiting = null;+rl.on("line", (l) => { if (waiting) { const w = waiting; waiting = null; w(l); } else lines.push(l); });+const line = () => (lines.length ? Promise.resolve(lines.shift()) : new Promise((res) => { waiting = res; }));+const say = (word, rest) => console.log(`${word} ${rest}`);+const PASS = "a long sentence nobody else would guess";++async function open(browser) {+ const ctx = await browser.newContext({ acceptDownloads: true });+ const page = await ctx.newPage();+ page.on("pageerror", (e) => say("PAGEERROR", e.message));+ // GitHub's public API, as the console asks it for a login's numeric id.+ await page.route("https://api.github.com/users/**", (r) => r.fulfill({ status: 200, contentType: "application/json", headers: { "Access-Control-Allow-Origin": "*" }, body: JSON.stringify({ login: "b-i-t-8", id: 212773752 }) }));+ await page.goto(hub + "/");+ await page.waitForSelector("#identity-tab:not([hidden])");+ await page.click("#identity-tab");+ return page;+}+const text = (page, sel) => page.textContent(sel).then((t) => t.trim());++(async () => {+ const browser = await chromium.launch(process.env.PYRTAN_BROWSER ? { executablePath: process.env.PYRTAN_BROWSER } : {});+ try {+ // A: make the identity, and save its backup.+ const a = await open(browser);+ await a.click("#ident-new");+ await a.fill("#ident-pass", PASS);+ await a.fill("#ident-pass2", PASS);+ await a.click("#ident-new-form button");+ await a.waitForSelector("#ident-root:not([hidden])", { timeout: 30000 });+ say("UNSAVED", await text(a, "#root-backup-state"));+ const [dl] = await Promise.all([a.waitForEvent("download"), a.click("#root-download")]);+ const backupPath = path.join(downloads, dl.suggestedFilename());+ await dl.saveAs(backupPath);+ await a.waitForFunction(() => document.querySelector("#root-backup-state").textContent.includes("Backed up"));+ say("BACKUP", backupPath);+ await a.fill("#root-gh", "b-i-t-8");+ await a.click("#root-gh-go");+ say("PRIVATEREPO", await a.getAttribute("#root-gh-file", "href"));+ say("ROOT", await text(a, "#root-id"));+ await line(); // the test grants this root++ // A: claim a name at a network, and at pyrtan.com.+ await a.check('input[name="claim-where"][value="network"]');+ await a.fill("#claim-name", "alice@names.example.org");+ await a.fill("#claim-repo", "https://github.com/example/names");+ await a.click("#claim-network button");+ say("CLAIMLINK", await (await a.waitForSelector("#claim-open")).getAttribute("href"));+ await a.check('input[name="claim-where"][value="pyrtan"]');+ await a.fill("#claim-login", "b-i-t-8");+ await a.click("#claim-pyrtan button");+ await a.waitForFunction(() => ((document.querySelector("#claim-open") || {}).href || "").includes("/b-i-t-8/pyrtan/new/"));+ say("PYRTANLINK", await a.getAttribute("#claim-open", "href"));++ // B: a device; A signs its note; B pastes it.+ const b = await open(browser);+ await b.click("#ident-make");+ await b.waitForSelector("#ident-on:not([hidden])");+ const device = await text(b, "#ident-id");+ say("DEVICE", device);+ await a.fill("#dev-code", device);+ await a.fill("#dev-label", "phone");+ await a.click("#dev-form button[type=submit]");+ const note = await text(a, "#dev-out pre");+ say("NOTE", JSON.stringify(JSON.parse(note)));+ await b.fill("#ident-note", note);+ await b.click("#ident-note-form button");+ await b.waitForFunction(() => document.querySelector("#ident-paired").textContent.includes("speaks for"));++ // A keeps the hub's grants in the Access tab.+ await a.click("#access-tab");+ await a.waitForSelector("#grant-table tbody tr");+ await a.fill("#grant-who", device);+ await a.uncheck('#grant-may input[value="ask"]');+ await a.check('#grant-may input[value="chat"]');+ await a.click("#grant-form button");+ await a.waitForFunction((d) => document.querySelector("#grant-table").textContent.includes(d.slice(0, 8)), device);+ say("GRANTS", String(await a.$$eval("#grant-table tbody tr", (r) => r.length)));++ // B asks, with no token: the hub grants its root ask.+ const ask = async (page, words) => {+ await page.click('#tabs a[data-tab="ask"]');+ await page.fill("#text", words);+ const before = await page.$$eval("#out .card", (c) => c.length);+ await page.click("#go");+ await page.waitForFunction((n) => document.querySelectorAll("#out .card").length > n, before);+ return text(page, "#out .card");+ };+ say("ASKED", await ask(b, "echo from the phone"));++ // A revokes B and gives the hub its newer document; B is refused.+ await a.click("#identity-tab");+ a.once("dialog", (d) => d.accept());+ await a.click(`#dev-table button[data-revoke="${device}"]`);+ await a.waitForFunction(() => document.querySelector("#dev-table").textContent.includes("revoked"));+ await a.click("#doc-push");+ await a.waitForFunction(() => document.querySelector("#problem").textContent.includes("newest document"));+ say("REVOKED", await ask(b, "echo once more"));++ // C: a new browser restores A from the backup file.+ const c = await open(browser);+ await c.click("#ident-restore-open");+ await c.setInputFiles("#ident-file", backupPath);+ await c.fill("#ident-restore-pass", PASS);+ await c.click("#ident-restore-form button");+ await c.waitForSelector("#ident-root:not([hidden])", { timeout: 30000 });+ say("RESTORED", `${await text(c, "#root-id")} ${await c.$$eval("#dev-table tbody tr", (r) => r.length)}`);+ say("DONE", "ok");+ } finally {+ await browser.close();+ rl.close();+ }+})().catch((e) => { say("ERROR", e.message.split("\n")[0]); process.exit(1); });diff --git a/tests/conformance/identity.cjs b/tests/conformance/identity.cjs index 3ddb431..f219d0a 100644 --- a/tests/conformance/identity.cjs +++ b/tests/conformance/identity.cjs@@ -33,6 +33,24 @@ const bytes = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16const key = await Id.fromSeedAndPublic(bytes(r.seed), bytes(r.public)); check(`headers ${label}`, r.headers, await Id.signRequest(key, r.audience, r.method, r.path, r.body, r.note, r.date)); }+ for (const d of v.documents) {+ const key = await Id.fromSeedAndPublic(bytes(d.seed), bytes(d.public)), m = d.make;+ let got;+ if (d.kind === "identity") got = await Id.makeIdentity(key, m);+ else if (d.kind === "device") got = await Id.makeDevice(key, m.device, m.may, m);+ else if (d.kind === "name") got = await Id.makeName(key, m.name, m);+ else if (d.kind === "handover") got = await Id.makeName(key, m.name, Object.assign({}, m, { handoverFrom: await Id.fromSeedAndPublic(bytes(d.from_seed), bytes(d.from_public)) }));+ else if (d.kind === "release") got = await Id.makeRelease(key, m.name, m);+ check(`signs a ${d.kind}`, d.expect, got);+ }+ for (const b of v.backups) {+ const seed = await Id.openBackup(b.expect, b.passphrase);+ check("opens a backup Python wrote", b.seed, Id.hex(seed));+ check("writes the backup Python writes", b.expect, await Id.makeBackup(bytes(b.seed), b.expect.id, b.passphrase, { salt: bytes(b.salt), iv: bytes(b.iv), iterations: b.iterations, made: b.made, document: b.expect.document }));+ let refused = null; try { await Id.openBackup(b.expect, b.passphrase + "x"); } catch (e) { refused = e.message; }+ check("refuses a wrong passphrase", true, /does not open/.test(refused || ""));+ check("refuses a weakened kdf", true, /at least/.test(Id.backupProblem(Object.assign({}, b.expect, { kdf: Object.assign({}, b.expect.kdf, { iterations: 1000 }) })) || ""));+ }console.log(`${total - failed}/${total} agree`); process.exit(failed ? 1 : 0); })(); diff --git a/tests/conformance/identity.json b/tests/conformance/identity.json index d24e299..6220912 100644 --- a/tests/conformance/identity.json +++ b/tests/conformance/identity.json@@ -3,6 +3,60 @@"pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb", "PYR:H3RKRJZIHSZP24UJIPNKCJ7PBHSIGBY2RNF4NGN2IURPBGYUZ7PGYOYB" ],+ "backups": [+ {+ "expect": {+ "cipher": {+ "iv": "ZGVmZ2hpamtsbW5v",+ "name": "AES-256-GCM"+ },+ "data": "2w3i7Wa4kjB0nacZ4_8kxV0i8Y_I9Zont9uFpCu8I3zKs3rwJrTgQRubHAQkO4lg",+ "document": {+ "devices": [+ {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ }+ ],+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "kind": "person",+ "pyrtan-identity": 1,+ "revoked": [+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb"+ ],+ "routes": [+ "hub.example"+ ],+ "sig": "lrOT3YST22ZKxAragEUIeETBWexBcrQGXo3q-4w1KgH6e91hminoPmY2nJopC_4ED8vPi_h2AR9FBn1htbvCAQ",+ "updated": 1793000000+ },+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "kdf": {+ "iterations": 100000,+ "name": "PBKDF2-SHA256",+ "salt": "AAECAwQFBgcICQoLDA0ODw"+ },+ "made": 1793000000,+ "pyrtan-key-backup": 1+ },+ "iterations": 100000,+ "iv": "6465666768696a6b6c6d6e6f",+ "made": 1793000000,+ "passphrase": "correct horse battery staple é",+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "salt": "000102030405060708090a0b0c0d0e0f",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ }+ ],"canonical": [ { "canonical": "null",@@ -76,6 +130,154 @@} } ],+ "documents": [+ {+ "expect": {+ "devices": [+ {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ }+ ],+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "kind": "person",+ "pyrtan-identity": 1,+ "revoked": [+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb"+ ],+ "routes": [+ "hub.example"+ ],+ "sig": "lrOT3YST22ZKxAragEUIeETBWexBcrQGXo3q-4w1KgH6e91hminoPmY2nJopC_4ED8vPi_h2AR9FBn1htbvCAQ",+ "updated": 1793000000+ },+ "kind": "identity",+ "make": {+ "devices": [+ {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ }+ ],+ "revoked": [+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb"+ ],+ "routes": [+ "hub.example"+ ],+ "updated": 1793000000+ },+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ },+ {+ "expect": {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ },+ "kind": "device",+ "make": {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ]+ },+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ },+ {+ "expect": {+ "domain": "example.org",+ "github": 42,+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "issued": 1793000000,+ "name": "alice",+ "pyrtan-name": 1,+ "sig": "8YJ06frvbX95dgpHib2jrIKGEXOAcPHgPfKJPVPHR1_nMSnce65VXVv5KNiAqIrJMGpzSHqXWSfBzAq8EQlxAQ"+ },+ "kind": "name",+ "make": {+ "extra": {+ "github": 42+ },+ "issued": 1793000000,+ "name": "Alice@Example.org"+ },+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ },+ {+ "expect": {+ "domain": "example.org",+ "handover": {+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "lIIccU9haQd4hSCS9X0JQyhJjEdnaE-2mqsOT0-HpmPTjFkQBDx_i5p9KBEbGmk0PFgolqw5UbMvXgraRKbuDQ"+ },+ "id": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "issued": 1793000000,+ "name": "alice",+ "pyrtan-name": 1,+ "sig": "RtfCmU-f2AnmBQ9tVW1HPo-1RgwOUIRVk-DKrIA9VE_8iDmtHHE53TFtgUS-Ol_HUPqUcPxQSneJ0keEWVfRAw"+ },+ "from_public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "from_seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",+ "kind": "handover",+ "make": {+ "issued": 1793000000,+ "name": "alice@example.org"+ },+ "public": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",+ "seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"+ },+ {+ "expect": {+ "domain": "example.org",+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "issued": 1793000000,+ "name": "alice",+ "pyrtan-name-release": 1,+ "sig": "qz5VZ2Fjp36NCijTxhxjn54Rh309qyNStpvF19bw_UutxEF-QhkOWwNeEWjoQzlkEq5HNahgzPhBA0IHnd3XAQ"+ },+ "kind": "release",+ "make": {+ "issued": 1793000000,+ "name": "alice@example.org"+ },+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ }+ ],"ids": [ { "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab", diff --git a/tests/conformance/make_identity_vectors.py b/tests/conformance/make_identity_vectors.py index 135a5bf..f17969b 100644 --- a/tests/conformance/make_identity_vectors.py +++ b/tests/conformance/make_identity_vectors.py@@ -56,6 +56,21 @@ def build() -> dict:"body": body, "note": note_for, "message": identity.request_message(audience, method, path, str(DATE), body).decode("utf-8"), "headers": identity.sign_request(key, audience, method, path, body, note=note_for, date=DATE), })+ # What a root signs, at fixed times: identical bytes from both, Ed25519 being deterministic.+ other = keys[2]+ doc = identity.make_identity(root, routes=["hub.example"], devices=[note], revoked=[other.id], updated=DATE)+ documents = [+ {"kind": "identity", "seed": SEEDS[0], "public": public[0], "make": {"routes": ["hub.example"], "devices": [note], "revoked": [other.id], "updated": DATE}, "expect": doc},+ {"kind": "device", "seed": SEEDS[0], "public": public[0], "make": {"device": phone.id, "may": ["ask", "chat"], "label": "browser", "issued": DATE - 100, "expires": DATE + 86400}, "expect": note},+ {"kind": "name", "seed": SEEDS[0], "public": public[0], "make": {"name": "Alice@Example.org", "issued": DATE, "extra": {"github": 42}}, "expect": identity.make_name(root, "Alice@Example.org", issued=DATE, github=42)},+ {"kind": "handover", "seed": SEEDS[1], "public": public[1], "from_seed": SEEDS[0], "from_public": public[0], "make": {"name": "alice@example.org", "issued": DATE},+ "expect": identity.make_name(phone, "alice@example.org", issued=DATE, handover_from=root)},+ {"kind": "release", "seed": SEEDS[0], "public": public[0], "make": {"name": "alice@example.org", "issued": DATE}, "expect": identity.make_release(root, "alice@example.org", issued=DATE)},+ ]+ # A backup at the fewest rounds a backup may claim, so the test is quick; the format is the same at 600,000.+ salt, iv = bytes(range(16)), bytes(range(100, 112))+ backups = [{"seed": SEEDS[0], "public": public[0], "passphrase": "correct horse battery staple é", "salt": salt.hex(), "iv": iv.hex(), "iterations": identity.MIN_ITERATIONS, "made": DATE,+ "expect": identity.make_backup(root, "correct horse battery staple é", document=doc, salt=salt, iv=iv, iterations=identity.MIN_ITERATIONS, made=DATE)}]samples = [b"", b"abc", bytes(range(256)), b"x" * 135, b"x" * 136, b"x" * 137] return { "sha3_256": [{"in": s.hex(), "out": hashlib.sha3_256(s).hexdigest()} for s in samples],@@ -65,6 +80,8 @@ def build() -> dict:"canonical": [{"value": v, "canonical": canonical(v).decode("utf-8")} for v in VALUES], "requests": requests, "note": note,+ "documents": documents,+ "backups": backups,} diff --git a/tests/test_console_identity.py b/tests/test_console_identity.py new file mode 100644 index 0000000..ffeb041 --- /dev/null +++ b/tests/test_console_identity.py@@ -0,0 +1,98 @@+"""The console's identity, with no shell: tests/browser/console_gui.cjs drives it in Chromium."""++import json+import shutil+import subprocess+from urllib.parse import parse_qs, urlsplit++import pytest++pytest.importorskip("cryptography")++from pyrtan import identity # noqa: E402+from pyrtan.hub import serve_hub # noqa: E402+from conftest import ROOT # noqa: E402++ECHO = """+name = "echo"+description = "echoes"+start = "say"++[[triggers]]+pattern = "echo {text}"++[inputs.text]+type = "str"++[nodes.say]+kind = "done"+say = "echo: {{ inputs.text }}"+"""+PASS = "a long sentence nobody else would guess"+++def _node_playwright() -> bool:+ if shutil.which("node") is None:+ return False+ return subprocess.run(["node", "-e", "require('playwright')"], capture_output=True).returncode == 0+++@pytest.mark.skipif(not _node_playwright(), reason="needs node with playwright, and a Chromium")+def test_the_console_does_identity_without_a_shell(tmp_path):+ (tmp_path / "t").mkdir()+ (tmp_path / "t" / "echo.toml").write_text(ECHO)+ (tmp_path / "dl").mkdir()+ h, srv = serve_hub(port=0, quiet=True, trees=[tmp_path / "t"], federate=True, token="tok", poll_every=60)+ said: dict[str, str] = {}+ p = subprocess.Popen(["node", str(ROOT / "tests" / "browser" / "console_gui.cjs"), srv.url.rstrip("/"), str(tmp_path / "dl")],+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, cwd=str(ROOT))+ try:+ for line in p.stdout: # a Chromium helper may hold the pipe open after node exits: stop at the last line+ word, _, rest = line.rstrip("\n").partition(" ")+ said[word] = rest+ if word == "ROOT":+ h.grants.allow(rest, ["ask", "chat", "operate"])+ p.stdin.write("go\n")+ p.stdin.flush()+ if word in ("DONE", "ERROR", "PAGEERROR"):+ break+ finally:+ try:+ p.wait(timeout=60)+ except subprocess.TimeoutExpired:+ p.kill()+ h.stop()+ srv.stop()+ assert "ERROR" not in said and "PAGEERROR" not in said, said+ root = said["ROOT"]++ # The backup: refused the page until saved, then the file this console wrote opens with the CLI's code.+ assert "Save your backup now" in said["UNSAVED"]+ backup = json.loads(open(said["BACKUP"]).read())+ assert backup["id"] == root and backup["kdf"]["iterations"] == identity.BACKUP_ITERATIONS+ assert identity.open_backup(backup, PASS).id == root+ repo_link = urlsplit(said["PRIVATEREPO"])+ assert repo_link.path == "/b-i-t-8/pyrtan-identity/new/main" and json.loads(parse_qs(repo_link.query)["value"][0])["id"] == root++ # The claims: GitHub's new-file page, filled in with a claim the key signed.+ link = urlsplit(said["CLAIMLINK"])+ assert link.netloc == "github.com" and link.path == "/example/names/new/main/names"+ q = parse_qs(link.query)+ assert q["filename"] == ["alice.json"]+ claim = json.loads(q["value"][0])+ assert identity.check_name(claim, name="alice@names.example.org") is None and claim["id"] == root+ link = urlsplit(said["PYRTANLINK"])+ assert link.path == "/b-i-t-8/pyrtan/new/main/site/names"+ claim = json.loads(parse_qs(link.query)["value"][0])+ assert identity.check_name(claim, name="b-i-t-8@pyrtan.com") is None and claim["github"] == 212773752++ # The device: its note is the root's, and the hub answered it with no token, then refused it once revoked.+ note = json.loads(said["NOTE"])+ assert identity.check_device(note, root=root) is None and note["device"] == said["DEVICE"] and note["may"] == ["ask", "chat"]+ assert int(said["GRANTS"]) == 2+ assert "echo: from the phone" in said["ASKED"]+ assert "not-granted" in said["REVOKED"] or "may not" in said["REVOKED"]++ # Restored in another browser: the same identity, with its devices.+ restored, rows = said["RESTORED"].split()+ assert restored == root and int(rows) >= 1diff --git a/tests/test_identity.py b/tests/test_identity.py index 09e9469..2b93c5f 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py@@ -316,3 +316,66 @@ def test_cli_check_identifiers(tmp_path, capsys):def test_cli_without_an_identity(tmp_path, capsys): code, _, err = run(capsys, "id", "show", "--dir", str(tmp_path / "nobody")) assert code == 1 and "pyrtan id new" in err+++# ---------------------------------------------------------------- backups, releases, handovers+++@needs_crypto+def test_backups(root, device):+ doc = identity.make_identity(root, updated=int(time.time()) - 10)+ b = identity.make_backup(root, "correct horse battery", document=doc, iterations=identity.MIN_ITERATIONS)+ assert identity.backup_problem(b) is None and identity.check_any(b) == ("backup", None)+ assert identity.open_backup(b, "correct horse battery").id == root.id+ with pytest.raises(IdentityError, match="passphrase does not open"):+ identity.open_backup(b, "wrong horse battery")+ # The ciphertext is bound to the identity it names, and a weakened kdf is refused.+ with pytest.raises(IdentityError, match="passphrase does not open"):+ identity.open_backup({**b, "id": device.id, "document": None}, "correct horse battery")+ assert "iterations" in identity.backup_problem({**b, "kdf": {**b["kdf"], "iterations": 1000}})+ assert "document" in identity.backup_problem({**b, "document": identity.make_identity(device)})+ with pytest.raises(IdentityError, match="at least 8"):+ identity.make_backup(root, "short")+ assert identity.make_backup(root, "correct horse battery", iterations=identity.MIN_ITERATIONS)["kdf"]["salt"] != b["kdf"]["salt"]+++@needs_crypto+def test_releases_and_handovers(root, device):+ release = identity.make_release(root, "alice@example.org", issued=NOW)+ assert identity.check_release(release, name="alice@example.org") is None+ assert "bob" in identity.check_release(release, name="bob@example.org")+ assert "signature" in identity.check_release({**release, "id": device.id})+ claim = identity.make_name(device, "alice@example.org", issued=NOW)+ assert "without a handover" in identity.check_handover(claim, root.id)+ moved = identity.make_name(device, "alice@example.org", issued=NOW, handover_from=root)+ assert identity.check_name(moved) is None # still the new key's own claim+ assert identity.check_handover(moved, root.id) is None+ assert "not" in identity.check_handover(moved, device.id)+ assert "not signed" in identity.check_handover({**moved, "issued": NOW + 1}, root.id)+++@needs_crypto+def test_cli_backup_and_restore(tmp_path, capsys, monkeypatch):+ monkeypatch.setenv("PP", "correct horse battery staple")+ a, c = str(tmp_path / "a"), str(tmp_path / "c")+ run(capsys, "id", "new", "--dir", a)+ phone = identity.Key.generate().id+ run(capsys, "id", "device", "add", phone, "--dir", a)+ code, out, _ = run(capsys, "id", "backup", "--out", str(tmp_path / "b.json"), "--passphrase-env", "PP", "--dir", a)+ assert code == 0 and "never in a public one" in out+ assert stat.S_IMODE(os.stat(tmp_path / "b.json").st_mode) == 0o600+ assert run(capsys, "id", "backup", "--out", str(tmp_path / "b.json"), "--passphrase-env", "PP", "--dir", a)[0] == 1 # not overwritten+ code, out, _ = run(capsys, "id", "check", str(tmp_path / "b.json"))+ assert code == 0 and "a key backup of" in out+ code, out, _ = run(capsys, "id", "restore", str(tmp_path / "b.json"), "--passphrase-env", "PP", "--dir", c)+ assert code == 0 and "again" in out+ code, out, _ = run(capsys, "id", "show", "--dir", c)+ assert phone in out # the document came back with the key+ monkeypatch.setenv("PP", "not the passphrase")+ code, _, err = run(capsys, "id", "restore", str(tmp_path / "b.json"), "--passphrase-env", "PP", "--dir", str(tmp_path / "d"))+ assert code == 1 and "does not open" in err+ other = str(tmp_path / "other")+ run(capsys, "id", "new", "--dir", other)+ monkeypatch.setenv("PP", "correct horse battery staple")+ code, _, err = run(capsys, "id", "restore", str(tmp_path / "b.json"), "--passphrase-env", "PP", "--dir", other)+ assert code == 1 and "already holds" in errdiff --git a/tests/test_names.py b/tests/test_names.py index 055d257..4ab9726 100644 --- a/tests/test_names.py +++ b/tests/test_names.py@@ -1,4 +1,5 @@import json+import shutilimport subprocess import sys@@ -269,3 +270,165 @@ def test_cli_wellknown(tmp_path, capsys, alice):(d / "bad.json").write_text("{}") code, _, err = run(capsys, "id", "wellknown", str(d), "--domain", "example.org", "--out", str(tmp_path / "site")) assert code == 1 and "bad.json" in err+++# ---------------------------------------------------------------- a network's names, owned by their keys++DOMAIN = "names.example.org"+++def write(d, name, doc):+ d.mkdir(parents=True, exist_ok=True)+ (d / name).write_text(json.dumps(doc))+++def test_key_rule_new_names_and_handovers(tmp_path, alice, bob):+ base, head = tmp_path / "base", tmp_path / "head"+ write(head, "alice.json", identity.make_name(alice, f"alice@{DOMAIN}"))+ assert names.gate_key(head, None, DOMAIN) == [] # a new name: anyone who signs it+ shutil.copytree(head, base)+ write(head, "alice.json", identity.make_name(alice, f"alice@{DOMAIN}"))+ assert names.gate_key(head, base, DOMAIN) == [] # re-signed by the same key+ write(head, "alice.json", identity.make_name(bob, f"alice@{DOMAIN}"))+ assert "without a handover" in messages(names.gate_key(head, base, DOMAIN))+ write(head, "alice.json", identity.make_name(bob, f"alice@{DOMAIN}", handover_from=bob))+ assert "not" in messages(names.gate_key(head, base, DOMAIN))+ write(head, "alice.json", identity.make_name(bob, f"alice@{DOMAIN}", handover_from=alice))+ assert names.gate_key(head, base, DOMAIN) == [] # moved, with alice's handover+ write(head, "alice.json", identity.make_name(alice, "alice@elsewhere.org"))+ assert "the claim is for alice@elsewhere.org" in messages(names.gate_key(head, base, DOMAIN))+++def test_key_rule_releases_and_deletions(tmp_path, alice, bob):+ base, head = tmp_path / "base", tmp_path / "head"+ write(base, "alice.json", identity.make_name(alice, f"alice@{DOMAIN}"))+ head.mkdir()+ assert "not by deleting it" in messages(names.gate_key(head, base, DOMAIN))+ write(head, "alice.json", identity.make_release(bob, f"alice@{DOMAIN}"))+ assert "is held by" in messages(names.gate_key(head, base, DOMAIN))+ write(head, "alice.json", identity.make_release(alice, f"alice@{DOMAIN}"))+ assert names.gate_key(head, base, DOMAIN) == [] # given up by its key+ # Released, the name is free: anyone may claim it.+ shutil.rmtree(base)+ shutil.copytree(head, base)+ write(head, "alice.json", identity.make_name(bob, f"alice@{DOMAIN}"))+ assert names.gate_key(head, base, DOMAIN) == []+ write(head, "carol.json", identity.make_release(bob, f"carol@{DOMAIN}"))+ assert "nothing to release" in messages(names.gate_key(head, base, DOMAIN))+ # A release is not served as a name.+ count, _ = names.build_wellknown(base, tmp_path / "out", DOMAIN)+ assert count == 0+++def test_key_rule_documents_only_move_forward(tmp_path, alice):+ base, head = tmp_path / "base", tmp_path / "head"+ claim = identity.make_name(alice, f"alice@{DOMAIN}")+ old = identity.make_identity(alice, updated=1_700_000_000)+ new = identity.make_identity(alice, updated=1_700_000_100, revoked=[identity.Key.generate().id])+ write(base, "alice.json", claim)+ write(base, "alice.id.json", new)+ write(head, "alice.json", claim)+ write(head, "alice.id.json", old)+ assert "older than the one it replaces" in messages(names.gate_key(head, base, DOMAIN))+ (head / "alice.id.json").unlink()+ assert "not deleted" in messages(names.gate_key(head, base, DOMAIN))+ write(head, "alice.id.json", identity.make_identity(alice, updated=1_700_000_200))+ assert names.gate_key(head, base, DOMAIN) == []+++def test_names_init_and_the_github_gate_steps(tmp_path, alice, bob):+ """names init writes a repository; the gate workflow's merge-base step, against real git,+ judges only what a pull request changed, not what landed on main after it branched."""+ repo = tmp_path / "repo"+ written, kept = names.init_repo(repo, DOMAIN, host="github", pyrtan_ref="0123abc")+ rels = sorted(p.relative_to(repo).as_posix() for p in written)+ assert rels == [".github/workflows/names-gate.yml", ".github/workflows/names-pages.yml", "README.md", "names/.gitkeep", "pyrtan-names.json"]+ gate_yml = (repo / ".github/workflows/names-gate.yml").read_text()+ assert "pull_request_target" in gate_yml and "@0123abc" in gate_yml and DOMAIN in gate_yml and "merge-base" in gate_yml+ assert "upload-pages-artifact@v3" in (repo / ".github/workflows/names-pages.yml").read_text()+ assert names.repo_domain(repo) == DOMAIN+ assert names.init_repo(repo, DOMAIN)[1] # nothing overwritten the second time+ with pytest.raises(IdentityError):+ names.init_repo(tmp_path / "x", DOMAIN, pyrtan_ref="main; rm -rf /")+ git = lambda *a: subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *a], cwd=repo, check=True, capture_output=True, text=True)+ git("init", "-q", "-b", "main")+ write(repo / "names", "alice.json", identity.make_name(alice, f"alice@{DOMAIN}"))+ git("add", ".")+ git("commit", "-q", "-m", "alice")+ git("checkout", "-q", "-b", "pr")+ write(repo / "names", "bob.json", identity.make_name(bob, f"bob@{DOMAIN}"))+ git("add", ".")+ git("commit", "-q", "-m", "bob")+ git("checkout", "-q", "main")+ write(repo / "names", "carol.json", identity.make_name(identity.Key.generate(), f"carol@{DOMAIN}"))+ git("add", ".")+ git("commit", "-q", "-m", "carol, after the pull request branched")+ main_sha = git("rev-parse", "HEAD").stdout.strip()+ git("checkout", "-q", "pr")+ base = tmp_path / "base"+ git("worktree", "add", "--detach", str(base), git("merge-base", main_sha, "HEAD").stdout.strip())+ assert names.gate_key(repo / "names", base / "names", DOMAIN) == [] # carol is not a deletion by the pull request+ gate = subprocess.run([sys.executable, "-m", "pyrtan.cli", "names", "gate", str(repo / "names"), "--base", str(base / "names"), "--domain", DOMAIN], capture_output=True, text=True)+ assert gate.returncode == 0 and "open" in gate.stdout+++def test_names_init_for_gitlab(tmp_path):+ written, _ = names.init_repo(tmp_path / "gl", DOMAIN, host="gitlab")+ ci = (tmp_path / "gl" / ".gitlab-ci.yml").read_text()+ assert "merge_request_event" in ci and "merge-base" in ci and "public" in ci+ assert not (tmp_path / "gl" / ".github").exists()+++def test_a_hub_serves_its_networks_names(tmp_path, alice, bob):+ from pyrtan.hub import serve_hub+ from pyrtan.service import http_json+ d = tmp_path / "names"+ claim = identity.make_name(alice, f"alice@{DOMAIN}")+ doc = identity.make_identity(alice, routes=["hub.example"])+ write(d, "alice.json", claim)+ write(d, "alice.id.json", doc)+ write(d, "bob.json", identity.make_release(bob, f"bob@{DOMAIN}"))+ h, srv = serve_hub(port=0, quiet=True, token="tok", names=str(d), names_domain=DOMAIN, poll_every=60)+ try:+ url = srv.url.rstrip("/")+ assert http_json("GET", f"{url}/.well-known/pyrtan/name/alice.json") == (200, claim) # public, behind a token+ assert http_json("GET", f"{url}/.well-known/pyrtan/name/bob.json")[0] == 404 # released+ assert http_json("GET", f"{url}/.well-known/pyrtan/id/{alice.id[4:]}.json") == (200, doc)+ assert list(http_json("GET", f"{url}/.well-known/pyrtan/names.json")[1]["names"]) == ["alice"]+ # A new claim lands (a git pull, say) and is served at once.+ claim_b = identity.make_name(bob, f"bob@{DOMAIN}")+ write(d, "bob.json", claim_b)+ assert http_json("GET", f"{url}/.well-known/pyrtan/name/bob.json") == (200, claim_b)+ # And resolving through it is the same as through any domain.+ fetch = lambda u: http_json("GET", u.replace(f"https://{DOMAIN}", url))+ assert names.resolve(f"alice@{DOMAIN}", fetch=fetch)["id"] == alice.id+ finally:+ h.stop()+ srv.stop()+++def test_cli_names_and_releases(tmp_path, capsys, monkeypatch):+ repo = tmp_path / "repo"+ code, out, _ = run(capsys, "names", "init", str(repo), "--domain", DOMAIN, "--host", "plain")+ assert code == 0 and "pyrtan-names.json" in out and not (repo / ".github").exists()+ me, new = str(tmp_path / "me"), str(tmp_path / "new")+ run(capsys, "id", "new", "--dir", me)+ code, out, err = run(capsys, "id", "name", f"alice@{DOMAIN}", "--into", str(repo), "--dir", me)+ assert code == 0, err+ assert (repo / "names" / "alice.json").is_file() and (repo / "names" / "alice.id.json").is_file()+ assert "from the GitHub account" not in out # a network's repository asks no GitHub id+ shutil.copytree(repo / "names", tmp_path / "base")+ # Moving the name to a new key: the old key signs the handover.+ run(capsys, "id", "new", "--dir", new)+ (repo / "names" / "alice.id.json").unlink()+ code, _, err = run(capsys, "id", "name", f"alice@{DOMAIN}", "--into", str(repo), "--handover-from", str(tmp_path / "me" / "root.key"), "--dir", new)+ assert code == 0, err+ code, out, _ = run(capsys, "names", "gate", str(repo / "names"), "--base", str(tmp_path / "base"), "--domain", DOMAIN)+ assert code == 0 and "open" in out+ # Giving it up: a release in its place.+ code, _, _ = run(capsys, "id", "release", f"alice@{DOMAIN}", "--out", str(repo / "names" / "alice.json"), "--dir", new)+ (repo / "names" / "alice.id.json").unlink()+ code, out, _ = run(capsys, "id", "check", str(repo / "names" / "alice.json"))+ assert code == 0 and "gives up" in out+ code, out, _ = run(capsys, "names", "gate", str(repo / "names"), "--base", str(tmp_path / "base"), "--domain", DOMAIN)+ assert code == 1 and "is held by" in out # the base still holds it under the first keydiff --git a/uv.lock b/uv.lock index 4100cbc..627ee6c 100644 --- a/uv.lock +++ b/uv.lock@@ -304,7 +304,7 @@ wheels = [[[package]] name = "pyrtan"-version = "0.18.0"+version = "0.19.0"source = { editable = "." } [package.optional-dependencies]