Identity, step three (the console): a device key in the browser, signing for it
Claude · 2026-10-27 · bd020d109be5b9a5f5cf7546846435d6bae132e8 · parents f7e3adc
src/pyrtan/js/identity.js, inlined into the console, does what the console needs of identity.py: SHA3-256 (WebCrypto has none) and SHA-256, identifiers, canonical JSON, the request message, and signing with an Ed25519 key that only crypto.subtle holds, non-extractable, in IndexedDB. The console's Identity tab makes the key, takes the device note `pyrtan id device add` prints, and from then on api() signs what the console sends; a member joined from there follows as the key, trusted when its root holds run. A page with no WebCrypto (http:// to a LAN address) says it cannot hold a key and works as before.
tests/conformance/identity.json is what Python says (digests, ids, refusals, canonical JSON, messages, signed headers); identity.cjs checks identity.js says the same, signatures included. tests/browser/console_identity.cjs drives the console in Chromium: a key, a note, a reload, an answer with no token, a trusted member.
A hub started with --allow needs the identity extra at start. /allow and /disallow need the token or a key granted operate even on a hub with no token, so nobody grants themselves run there. The design note, members, chat (a chat grant reaches the bots, so it is as good as ask), design, README and site README say what is built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
README.md | 37 ++++++-- _meta/chat.md | 7 +- _meta/design.md | 4 +- _meta/identity.md | 108 +++++++++++++++------- _meta/members.md | 6 +- site/README.md | 2 +- src/pyrtan/hub.py | 5 + src/pyrtan/identity.py | 5 + src/pyrtan/js/identity.js | 184 +++++++++++++++++++++++++++++++++++++ src/pyrtan/page.py | 137 ++++++++++++++++++++------- tests/browser/console_identity.cjs | 46 ++++++++++ tests/conformance/identity.cjs | 38 ++++++++ tests/conformance/identity.json | 245 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/conformance/make_identity_vectors.py | 79 ++++++++++++++++ tests/test_conformance.py | 21 +++++ tests/test_grants.py | 58 ++++++++++++ 16 files changed, 905 insertions(+), 77 deletions(-)
diff --git a/README.md b/README.md index 2a9d91f..86b45d4 100644 --- a/README.md +++ b/README.md@@ -177,6 +177,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).| `pyrtan index DIR` | Builds a catalog `index.json` from a directory of trees; what a catalog's CI runs. | | `pyrtan console --out FILE` | Writes the console as one static HTML file, to host anywhere. | | `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 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@@ -318,7 +319,7 @@ written out: `pyr:` and 56 characters, which anyone can check asignature against without asking anybody. `pyr:<key>@host` (a hub, an `.onion`) says where it can be reached; `alice@pyrtan.com` is a name a domain vouches for. No domain owns an identity, and none grants-anything by it: each hub will keep its own list of who may do what.+anything by it: each hub keeps its own list of who may do what.``` pyrtan id new # your root key, ~/.config/pyrtan/identity/@@ -337,9 +338,26 @@ 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)).-Built so far: identifiers, the signed documents (the identity document,-device notes, name claims), names and pins, and the command. Grants on-hubs come next. The design is in+**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);+a device gets what its root is granted, capped by its note.++```+pyrtan hub --token … --allow alice@pyrtan.com=ask,run # a name is resolved and pinned now+pyrtan allow bob@example.org --may chat --at HUB # or later, as the operator+pyrtan ask "how full is the disk" --at HUB --sign # signed with your key; no token+pyrtan id push --at HUB # your newest devices and revocations+```++A request signed with a key is checked against the hub's own id (`GET+/api` names it), so it cannot be replayed at another hub. The console's+**Identity** tab makes a key for the browser (WebCrypto, kept in the+browser, never readable by the page), takes the note `pyrtan id device+add` prints for it, and from then on signs what it sends; a member+joined from there follows as that key, and is trusted when its root+holds `run`. A revoked or expired device, or one whose grant is taken+back, is dropped. The design is in[`_meta/identity.md`](_meta/identity.md). ## The catalog@@ -395,6 +413,8 @@ pyrtan serve trees --hub http://hub:8700 --token … --gaps /var/lib/pyrtan/gapsA swarm shares one token: a service presents it when it registers, the hub presents it when it forwards, and a stranger can neither join nor ask.+People need not share it: grant their identities what each may do+(`--allow`, `pyrtan allow`; see *Identity* above).`--egress public` makes a service refuse any host that resolves to a loopback, private, link-local or metadata address, on the first URL and on every redirect, so a tree whose host comes from its input cannot be@@ -478,7 +498,8 @@ outbox logs a `Say` without its text. A hub answers a `Meet` only from ahub that follows it, and never passes one on. The lobby follows the hub's token: with one, only callers who have it-chat (a bot runs trees, so a lobby must not be a way round it). It is+chat, or a key the hub grants `chat` (a bot runs trees, so a lobby must+not be a way round it, and a `chat` grant is as good as `ask`). It istext only, a line is at most 1,000 characters, five in a burst and one a second after; a seat nobody polls for a minute is let go. There is no moderation but leaving. The design is in [`_meta/chat.md`](_meta/chat.md).@@ -587,7 +608,7 @@ slot refused. The whole set of rules is in``` src/pyrtan/ the package: tree, nlp, intent, template, conditions, extract, html, transport, runtime, trace, registry, gaps, grow,- service, hub, federation, identity, names, page (the console),+ service, hub, federation, identity, names, grants, page (the console),catalog, cli examples/ trees that run against real public APIs, and offline against tests/fixtures/examples.json@@ -617,8 +638,8 @@ for, drafted by rules), and the chat lobby (strangers on the hub and thehubs it follows, and bots made of trees) are built and tested; a swarm can require a token, 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, `pyrtan id`) are built;-grants on hubs are not yet. Not yet built: hosting.+name claims, names at pyrtan.com and pins, grants on hubs, the console's+key, `pyrtan id` and `pyrtan allow`) are built. Not yet built: hosting.See [`_meta/design.md`](_meta/design.md) for the runtime's design and [`_meta/platform.md`](_meta/platform.md) for pyrtan.com. diff --git a/_meta/chat.md b/_meta/chat.md index 0ac637d..ddbadcb 100644 --- a/_meta/chat.md +++ b/_meta/chat.md@@ -35,8 +35,11 @@ the hub's order. A seat nobody polls for a minute is let go and itspartner told, which is how a closed tab ends a conversation. The lobby follows the hub's access rule: with a token, only callers with-the token chat. That is deliberate. A bot runs trees, so a lobby open to-callers the hub would refuse at `/intent` would be a way round the token.+the token chat, or with a key the hub grants `chat` (`identity.md` §7).+That is deliberate. A bot runs trees, so a lobby open to callers the hub+would refuse at `/intent` would be a way round the token; for the same+reason a `chat` grant reaches the bots too, and is to be given to whom+the operator would let ask.A hub that wants strangers from the internet runs without a token, as every hub that serves strangers already does. diff --git a/_meta/design.md b/_meta/design.md index 9792584..b15f3e6 100644 --- a/_meta/design.md +++ b/_meta/design.md@@ -330,7 +330,9 @@ Five trees, all against real public APIs, all tested offline:- **Authentication between services.** v0.2 answers the first half: a shared bearer token, presented by services on registration and by the hub on every forward, with `/register` behind it. mTLS, and a token per- service rather than per swarm, wait for a swarm that needs them.+ service rather than per swarm, wait for a swarm that needs them. For+ people and their devices, 0.12 answers it with identities: a key per+ person, and per-hub grants of what each may do (`identity.md` §7).- **What a tree's `version` governs.** In the catalog it is what a pull pins. On the hub it is still undecided: a hub could prefer the highest version of a name across peers, or refuse to route to a peer whose diff --git a/_meta/identity.md b/_meta/identity.md index 3847628..1e90160 100644 --- a/_meta/identity.md +++ b/_meta/identity.md@@ -2,10 +2,10 @@| | | |---|---|-| **Status** | **Two steps built** (§10): identifiers, the three documents, signing and checking, and `pyrtan id`; then names (§6): resolving and pinning, `/.well-known/pyrtan/` for any domain, and pyrtan.com's names by pull request. Everything on the wire (§7) is proposed, not built. |+| **Status** | **Built, 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*. || **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`. Proposed: `src/pyrtan/federation.py` and `hub.py` (signed requests, grants), `src/pyrtan/page.py` (the console's device key), `site/names/` and `site/gate.py` (pyrtan.com's names), `/.well-known/pyrtan/` on any domain. |+| **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`. |## 0. The brief@@ -182,17 +182,21 @@ phone's console) gets its own key, and the root signs a note for it:refused; the root renews by signing a new one. - **Revocation** is the device id in the identity document's `revoked`, under a newer `updated`. A hub that holds the document re-reads it- (from a route, or its keepers) at least hourly and on any request from- a device it has not seen in a day; a revoked device's links are dropped- and its requests refused.+ (from the name's domain and the document's routes) at least hourly and+ on any request from a device it has not seen in a day, and takes a+ newer one pushed to it (`pyrtan id push --at HUB`) at once; a revoked+ device's links are dropped and its requests refused.- **A root may also sign requests itself.** That is the simple case for one machine; devices are for everything else. The console's device key is a non-extractable `CryptoKey` in IndexedDB.-Pairing a phone: the console makes its key and shows the device-identifier (and a QR code of it); `pyrtan id device add pyr:… --may-chat,run --label phone` on the machine with the root prints the note; the-console takes it pasted or scanned, or picks it up from the root's hub.+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).## 6. Names@@ -289,34 +293,62 @@ the five-minute window is the same as HMAC's. The hub checks thesignature, the device note (signed by its root, unexpired, not revoked), and then its grants.-**Grants.** A hub keeps a list, in its state file and from-`--allow`:+The audience is the hub's federation id, or the URL it was started at+(`--advertise`, else its own address), and `GET /api` names it, so a+client asks once and signs for it. The body is the canonical JSON the+request carries; a `POST` with no body is signed as `{}`, which is what+the hub reads it as. A bad signature is refused (401) on any hub. On a+hub with a token, a good one may do what its root is granted and nothing+else (403 otherwise, naming what it may do); on a hub without a token,+where anyone may do anything, a key only says who is asking, which is+what decides whether a member it signs for is trusted.++**Grants.** A hub keeps a list (`grants.json` beside its `--state`, or+`--grants FILE`, or in memory), from `--allow` at start and from its+operator at `/allow`:``` pyrtan hub --allow alice@pyrtan.com=ask,run --allow pyr:…=operate+pyrtan allow bob@example.org --may chat --at HUB # GET/POST /allow+pyrtan allowed --at HUB+pyrtan disallow bob@example.org --at HUB # POST /disallow``` A name is resolved and pinned when the grant is made; from then on the-grant is held against the identifier. What a request may do is the-grant's abilities intersected with the device note's `may`:+grant is held against the identifier, and the name is never resolved+again. `/allow` and `/disallow` need the token or a key granted+`operate`, even on a hub with no token: a grant of `run` makes a trusted+member, which an open hub never had, so there grants come only from+`--allow` at start. What a request may do is the grant's abilities intersected with+the device note's `may` (`grants.py`, `ability_for` says which a path+needs):| Ability | What it lets the identity do on this hub | What it stands in for today | | :-- | :-- | :-- | | `ask` | `/intent`, `/run`, `/resume` on a hub behind a token | the token, for asking |-| `chat` | a seat in a lobby behind a token | the token, for chat |+| `chat` | a seat in a lobby behind a token; its bots answer what `/intent` would, so it is as good as `ask` | the token, for chat || `run` | follow as a member and be handed trees (`members.md` §7) | joining with the token | | `operate` | everything the token allows | the token | The bearer token keeps working. A hub may have a token, grants, both or neither; a request with no key headers is judged exactly as it is today.+`POST /identity` takes a granted root's newer document from anyone (it+is signed); `GET /.well-known/pyrtan/id/<56>.json` serves the documents+the hub holds, so a hub is a route for the identities it grants. The+CLI signs with `--sign` (or `$PYRTAN_SIGN`) on every command that calls+a hub, as the root key in `$PYRTAN_IDENTITY`; device keys for the CLI+are later.**Members.** A member may follow with its device key as its actor id. The `Follow` is signed with the key as well as carrying the pair secret as now; once accepted, the link runs on HMAC exactly as today, and the key is not needed again until the hub re-reads the identity document. That keeps signatures off the polling path and leaves `members.md` §2-otherwise unchanged. A member whose root holds `run` is trusted; one-without is a keeper.+otherwise unchanged. A member whose root holds `run` is trusted+(`trusted_by: key`); one without is a keeper that says who it is. When+its note expires, its root's document revokes it, or its root loses+`run`, the hub drops the link; a member trusted by the token is not+touched.**Hubs.** A hub may have a root key too: `/actor` gains `key` and a signature over `{id, key}`. A follower pins the key. A hub that moves@@ -335,18 +367,21 @@ hub that checks Alice with a shared secret can also forge Alice.the way Playwright is `pyrtan[browser]`. Imported lazily by `identity.py`. Without it, encoding and checking identifiers still works (§2); `pyrtan id new`, signing and verifying say how to install- the extra; a hub started with a `pyr:` or name grant refuses to start,- loudly, rather than accept nothing; a hub with no grants ignores key- headers and behaves as today. Pyrtan's core stays standard-library+ the extra; a hub started with any `--allow` refuses to start, loudly,+ rather than hold grants nobody could prove; a hub without it answers a+ key-signed request 401, saying why, and everything else as before. Pyrtan's core stays standard-libraryonly, as `CONTRIBUTING.md` requires; identity is opt-in at each hub, and so is its dependency. - **The browser: WebCrypto's `Ed25519`,** shipped in all three engines, available only in secure contexts. The console at- `https://pyrtan.com/console/` and on any hub behind TLS has it; a- console served over plain `http://` on a LAN does not, and falls back- to today's anonymous member. That is the same limit that made the- console carry its own SHA-256 and HMAC (`members.md` §2), and it is- said in the Member tab rather than worked around.+ `https://pyrtan.com/console/`, on any hub behind TLS, and on+ `localhost` has it; a console served over plain `http://` to a LAN+ address does not, and falls back to the token and today's anonymous+ member. That is the same limit that made the console carry its own+ SHA-256 and HMAC (`members.md` §2), and it is said in the Identity tab+ rather than worked around. SHA3-256, for the identifier's checksum, is+ not in WebCrypto at all, so `identity.js` carries it, beside the+ SHA-256 the console already had.- **The fallback, if the dependency proves a problem:** a verify-only Ed25519 in pure Python, from RFC 8032's reference code. Verifying handles no secret, so timing does not leak anything; the risk is@@ -398,14 +433,23 @@ hub that checks Alice with a shared secret can also forge Alice.`catalog` workflow passing the author's numeric id, and the `pages` build serving the names. pyrtan.com hands out names; nothing yet grants by them.-- **Third: hubs.** Signed requests, `--allow` and grants, members- following with a device key, revocation refresh, the console's device- key and pairing, with conformance vectors (`tests/conformance/`) so the- console's checking agrees with Python's: the console needs SHA3-256 for- the checksum, which WebCrypto does not offer, so it carries its own,- as it carries SHA-256 today. This is the step that replaces handing out- the token.-- **Later:** hub keys in `/actor` and moves; a chat seat that opts in to+- **Third, built: hubs.** `identity.py` signs and verifies requests;+ `grants.py` keeps the grants, their documents and their refresh; the+ hub checks keys against its audience, enforces the abilities behind a+ token, takes `/allow`, `/disallow` and `/identity`, serves+ `/.well-known/pyrtan/id/`, trusts members by key and drops them when+ that lapses; `pyrtan hub --allow/--grants`, `pyrtan allow`, `allowed`,+ `disallow`, `id push`, and `--sign`. The console: `identity.js`+ (SHA3-256, identifiers, canonical JSON, request signing with WebCrypto,+ the key in IndexedDB), inlined; the Identity tab; `api()` signing; a+ member joining as the key. `tests/conformance/identity.json` holds+ what Python says, and `identity.cjs` checks that `identity.js` says the+ same, signatures included; `tests/browser/console_identity.cjs` drives+ 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+ root's hub; device keys for the CLI; hub keys in `/actor` and moves; a chat seat that opts in toshowing a verified name (chat stays anonymous by default, and still keeps nothing); reaching `.onion` locators, which needs a SOCKS proxy the transport does not have and an egress rule for it (`io.md`); diff --git a/_meta/members.md b/_meta/members.md index 5ca5641..9db5aa6 100644 --- a/_meta/members.md +++ b/_meta/members.md@@ -155,7 +155,11 @@ accepts any number, CSS selection is the browser's `DOMParser` plus adecides whether the hub puts its trees, and the sentences they answer, on a device: a keeper holds copies, a trusted device acts. The console sends the token it was connected with, so joining from the hub's own-console with the token entered is enough.+console with the token entered is enough. Since 0.12 a member may+instead follow as a key (`identity.md` §7): one whose root the hub+grants `run` is trusted the same way (`trusted_by: key`), and is dropped+when its note, its root's document or the grant says it may no longer+be. The console's Identity tab holds such a key.**Hand-out.** On a trusted follow, and on every refresh after, the hub gives each trusted member every tree it should run as a `Keep` of diff --git a/site/README.md b/site/README.md index b5f052d..7840c0f 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 seeds in the visitor's browser | `site/index.html` |-| `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it); point it at any hub started with `--cors https://pyrtan.com`; its Member tab joins that hub, and runs its trees when joined with the token | `pyrtan console --out` |+| `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it); point it at any hub started with `--cors https://pyrtan.com`; its Member tab 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` || `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, and `index.json` the command reads | `pyrtan index examples catalog --copy`, through `site/gate.py` | | `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` | diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 85b8d88..c242aec 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -640,6 +640,9 @@ class Hub:if method == "GET" and path == "/allow": return 200, {"grants": self.grants.view(), "audience": self.audience} if method == "POST" and path in ("/allow", "/disallow"):+ if not operator:+ # Even on a hub with no token: a grant of run makes a trusted member, which an open hub never had.+ return 403, {"status": "error", "reason": "not-granted", "say": "Grants change only with the operator's token or a key granted operate; a hub with no token takes them from --allow at start."}who = body.get("who") if not isinstance(who, str): return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"who\": \"pyr:…\" or \"name@domain\"" + (", \"may\": [\"ask\", ...]}." if path == "/allow" else "}.")}@@ -782,6 +785,8 @@ def serve_hub(where the list is kept (default: beside ``state``, else in memory).""" from .grants import parse_spec specs = [parse_spec(a) for a in allow or []]+ if specs:+ identity.require_signatures() # a grant nobody could prove is refused at start, not at the first requestkeep = grants or (str(Path(state).with_name("grants.json")) if state else None) granted = Grants(keep) for who, may in specs: diff --git a/src/pyrtan/identity.py b/src/pyrtan/identity.py index 8a7031c..54f2981 100644 --- a/src/pyrtan/identity.py +++ b/src/pyrtan/identity.py@@ -187,6 +187,11 @@ def _ed25519():return ed25519+def require_signatures() -> None:+ """IdentityError, saying how to install the extra, if this pyrtan cannot sign or verify."""+ _ed25519()++def _b64(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") diff --git a/src/pyrtan/js/identity.js b/src/pyrtan/js/identity.js new file mode 100644 index 0000000..28084fe --- /dev/null +++ b/src/pyrtan/js/identity.js@@ -0,0 +1,184 @@+/* identity.js: a pyrtan identity in JavaScript, for the console.+ *+ * 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.+ *+ * 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+ * never written out: a key is made, held and used only by crypto.subtle,+ * non-extractable, so a page without it (http:// on a LAN, an old+ * browser) holds no key and says so. The device key lives in IndexedDB.+ *+ * Runs in a browser as `PyrtanIdentity` and in node.+ */+(function (root, factory) {+ if (typeof module === "object" && module.exports) module.exports = factory();+ else root.PyrtanIdentity = factory();+})(typeof globalThis !== "undefined" ? globalThis : this, function () {+ "use strict";++ const PREFIX = "pyr:", ID_LENGTH = 56, VERSION_ED25519 = 1, REQUEST_TAG = "pyrtan-req-v1";+ 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; };++ // ------------------------------------------------------------ SHA-256++ const sha256 = (() => {+ const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));+ return (bytes) => {+ const l = bytes.length, bitLen = l * 8, padded = new Uint8Array(((l + 9 + 63) >> 6) << 6);+ padded.set(bytes); padded[l] = 0x80;+ const dv = new DataView(padded.buffer); dv.setUint32(padded.length - 4, bitLen >>> 0); dv.setUint32(padded.length - 8, Math.floor(bitLen / 4294967296));+ let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];+ const w = new Uint32Array(64);+ for (let o = 0; o < padded.length; o += 64) {+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + i * 4);+ for (let i = 16; i < 64; i++) { const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3), s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10); w[i] = (w[i-16] + s0 + w[i-7] + s1) >>> 0; }+ let [a,b,c,d,e,f,g,hh] = h;+ for (let i = 0; i < 64; i++) { const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25), ch = (e & f) ^ (~e & g), t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0, S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22), maj = (a & b) ^ (a & c) ^ (b & c), t2 = (S0 + maj) >>> 0; hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; }+ h = h.map((v, i) => (v + [a,b,c,d,e,f,g,hh][i]) >>> 0);+ }+ return new Uint8Array(h.flatMap((v) => [v >>> 24 & 255, v >>> 16 & 255, v >>> 8 & 255, v & 255]));+ };+ })();++ // ------------------------------------------------------------ SHA3-256 (FIPS 202), for the identifier's checksum++ const sha3_256 = (() => {+ const MASK = (1n << 64n) - 1n;+ const RC = [0x1n, 0x8082n, 0x800000000000808an, 0x8000000080008000n, 0x808bn, 0x80000001n, 0x8000000080008081n, 0x8000000000008009n, 0x8an, 0x88n, 0x80008009n, 0x8000000an, 0x8000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n, 0x800an, 0x800000008000000an, 0x8000000080008081n, 0x8000000000008080n, 0x80000001n, 0x8000000080008008n];+ const R = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]; // R[x][y]+ const rot = (v, n) => (n === 0 ? v : ((v << BigInt(n)) | (v >> BigInt(64 - n))) & MASK);+ function permute(A) {+ const C = new Array(5), B = new Array(25);+ for (let round = 0; round < 24; round++) {+ for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];+ for (let x = 0; x < 5; x++) { const D = C[(x + 4) % 5] ^ rot(C[(x + 1) % 5], 1); for (let y = 0; y < 5; y++) A[x + 5 * y] ^= D; }+ for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rot(A[x + 5 * y], R[x][y]);+ for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ (~B[(x + 1) % 5 + 5 * y] & MASK & B[(x + 2) % 5 + 5 * y]);+ A[0] ^= RC[round];+ }+ }+ const RATE = 136;+ return (bytes) => {+ const padded = new Uint8Array(Math.floor(bytes.length / RATE) * RATE + RATE);+ padded.set(bytes); padded[bytes.length] ^= 0x06; padded[padded.length - 1] ^= 0x80;+ const A = new Array(25).fill(0n);+ for (let o = 0; o < padded.length; o += RATE) {+ for (let i = 0; i < RATE / 8; i++) { let lane = 0n; for (let b = 7; b >= 0; b--) lane = (lane << 8n) | BigInt(padded[o + i * 8 + b]); A[i] ^= lane; }+ permute(A);+ }+ const out = new Uint8Array(32);+ for (let i = 0; i < 4; i++) { let lane = A[i]; for (let b = 0; b < 8; b++) { out[i * 8 + b] = Number(lane & 0xffn); lane >>= 8n; } }+ return out;+ };+ })();++ // ------------------------------------------------------------ base32 and base64url++ const B32 = "abcdefghijklmnopqrstuvwxyz234567";+ function b32(bytes) {+ let bits = 0, value = 0, out = "";+ for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } }+ if (bits > 0) out += B32[(value << (5 - bits)) & 31];+ return out;+ }+ function unb32(text) {+ let bits = 0, value = 0; const out = [];+ for (const ch of text) { const v = B32.indexOf(ch); if (v < 0) return null; value = (value << 5) | v; bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } }+ return new Uint8Array(out);+ }+ const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");+ const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4)), (c) => c.charCodeAt(0));++ // ------------------------------------------------------------ identifiers++ const checksum = (key, version) => sha3_256(concat(enc.encode(".pyrtan id"), key, new Uint8Array([version]))).slice(0, 2);+ function encode(key, version = VERSION_ED25519) {+ if (version !== VERSION_ED25519 || key.length !== 32) throw new Error(`a version ${version} key is 32 bytes, not ${key.length}`);+ return PREFIX + b32(concat(key, checksum(key, version), new Uint8Array([version])));+ }+ function decode(text) {+ if (typeof text !== "string" || !text.toLowerCase().startsWith(PREFIX)) throw new Error(`${JSON.stringify(text)} is not an identity: it starts ${PREFIX}`);+ const body = text.slice(PREFIX.length).toLowerCase();+ if (body.length !== ID_LENGTH) throw new Error(`not an identity: ${body.length} characters after ${PREFIX}, not ${ID_LENGTH}`);+ const raw = unb32(body);+ if (!raw || raw.length !== 35) throw new Error("not an identity: not base32");+ const key = raw.slice(0, 32), version = raw[34];+ if (version !== VERSION_ED25519) throw new Error(`version ${version}, which this console does not know`);+ const sum = checksum(key, version);+ if (sum[0] !== raw[32] || sum[1] !== raw[33]) throw new Error("not an identity: the checksum does not match (a typo?)");+ return key;+ }+ const valid = (text) => { try { decode(text); return true; } catch (e) { return false; } };+ const short = (id) => `${PREFIX}${id.slice(PREFIX.length, PREFIX.length + 4)}…${id.slice(-4)}`;++ // ------------------------------------------------------------ canonical JSON and the request message++ function canonical(v) {+ if (v === null || typeof v !== "object") return JSON.stringify(v);+ if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";+ return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";+ }+ function requestMessage(audience, method, path, date, body) {+ const digest = hex(sha256(body === null || body === undefined ? new Uint8Array() : enc.encode(canonical(body))));+ return enc.encode([REQUEST_TAG, audience.replace(/\/+$/, ""), method.toUpperCase(), path, String(date), digest].join("\n"));+ }++ // ------------------------------------------------------------ keys (crypto.subtle only)++ const subtle = () => (typeof crypto !== "undefined" && crypto.subtle) || null;+ async function available() {+ if (!subtle()) return false;+ try { await subtle().generateKey({ name: "Ed25519" }, false, ["sign", "verify"]); return true; } catch (e) { return false; }+ }+ async function idOf(publicKey) { return encode(new Uint8Array(await subtle().exportKey("raw", publicKey))); }+ async function generate() {+ const pair = await subtle().generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);+ return { id: await idOf(pair.publicKey), privateKey: pair.privateKey, publicKey: pair.publicKey };+ }+ async function fromSeedAndPublic(seed, publicKey) { // for the conformance vectors; a page never holds a seed+ const jwk = { kty: "OKP", crv: "Ed25519", d: b64url(seed), x: b64url(publicKey) };+ const privateKey = await subtle().importKey("jwk", jwk, { name: "Ed25519" }, false, ["sign"]);+ return { id: encode(publicKey), privateKey };+ }+ async function signRequest(key, audience, method, path, body, note, date) {+ const when = String(date !== undefined ? date : Math.floor(Date.now() / 1000));+ const sig = new Uint8Array(await subtle().sign({ name: "Ed25519" }, key.privateKey, requestMessage(audience, method, path, when, body)));+ const headers = { "X-Pyrtan-Key": key.id, "X-Pyrtan-Date": when, "X-Pyrtan-Key-Signature": b64url(sig) };+ if (note) {+ if (note.device !== key.id) throw new Error(`the note is for ${note.device}, not this key`);+ headers["X-Pyrtan-Device"] = b64url(enc.encode(canonical(note)));+ }+ return headers;+ }+ /** What is wrong with a pasted device note for this key, or null. The hub checks the signature. */+ function noteProblem(note, deviceId, now) {+ if (!note || typeof note !== "object" || note["pyrtan-device"] !== 1) return "not a device note";+ if (note.device !== deviceId) return `the note is for ${note.device}, not this browser's key`;+ if (!valid(note.root)) return "the note names no root";+ if (typeof note.sig !== "string") return "the note is not signed";+ if (!(note.expires > (now !== undefined ? now : Date.now() / 1000))) return "the note has expired";+ return null;+ }++ // ------------------------------------------------------------ the device key, in IndexedDB++ const store = {+ db: null,+ async open() {+ if (this.db) return this.db;+ this.db = await new Promise((res, rej) => { const r = indexedDB.open("pyrtan-identity", 1); r.onupgradeneeded = () => r.result.createObjectStore("keys"); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });+ return this.db;+ },+ async get() { const db = await this.open(); return new Promise((res, rej) => { const r = db.transaction("keys").objectStore("keys").get("device"); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); },+ 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 };+});diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 55ad06f..7e2a307 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -15,6 +15,13 @@ from elsewhere (it finds out by asking ``api`` on its own origin), it showsa connect box for a hub URL and a token, keeps them in the browser's local storage, and sends the token as a bearer. The hub must then allow the console's origin with ``--cors``. No external assets, no build step.++On a hub of 0.12 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+anything but localhost has no WebCrypto, holds no key, and says so.""" from __future__ import annotations@@ -29,6 +36,8 @@ from . import __version__PYRTAN_JS = (Path(__file__).parent / "js" / "pyrtan.js").read_text(encoding="utf-8") # The wordmark's colour picker, inlined into the console and copied beside pyrtan.com's pages. THEME_JS = (Path(__file__).parent / "js" / "theme.js").read_text(encoding="utf-8")+# Identifiers, canonical JSON and request signing, inlined into the console (_meta/identity.md).+IDENTITY_JS = (Path(__file__).parent / "js" / "identity.js").read_text(encoding="utf-8")PAGE = r"""<!doctype html> <html lang="en">@@ -41,6 +50,7 @@ PAGE = r"""<!doctype html><script src="pyrtan.js"></script> <meta name="theme-color" content="#c2410c"> <script>__THEME__</script>+<script>__IDENTITY__</script><style> :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 } @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }@@ -125,6 +135,7 @@ PAGE = r"""<!doctype html><a href="#unanswered" data-tab="unanswered">Unanswered</a> <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="#chat" data-tab="chat" id="chat-tab" hidden>Chat</a> <a href="#api" data-tab="api">API</a> </nav>@@ -217,6 +228,29 @@ PAGE = r"""<!doctype html></div> </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>+ </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>+ </div>+ </div>+ </div>+ </section>+<section id="tab-chat" hidden> <h2>Talk to a stranger</h2> <p class="mute" style="font-size:14px;margin-top:0">You are paired at random: with someone else on this hub or on a hub it follows, or with a bot. A bot is a set of trees on the network, not a person and not a model; each of its lines carries the path that produced it, and what it cannot answer it says so, with the tree that would. Nobody has a name and nothing is kept: the conversation is gone when either of you leaves. <span id="chat-count"></span></p>@@ -276,10 +310,13 @@ const store = {// base: "" when this page is served by a Pyrtan (relative URLs); otherwise the hub URL the person gave. let base = "", token = "", remote = false, isHub = false;-function api(path, init) {+async function api(path, init) {const headers = Object.assign({"Accept": "application/json"}, (init && init.headers) || {}); if (token) headers["Authorization"] = "Bearer " + token; const url = base ? base.replace(/\/+$/, "") + "/" + path : path;+ const method = ((init && init.method) || "GET").toUpperCase();+ const body = init && init.body ? JSON.parse(init.body) : (method === "POST" ? {} : null);+ Object.assign(headers, await ident.headers(method, "/" + path.split("?")[0].replace(/^\/+/, ""), body));return fetch(url, Object.assign({}, init, {headers})); } async function json(path, init) {@@ -289,6 +326,59 @@ async function json(path, init) {} function problem(msg, bad) { $("#problem").innerHTML = msg ? `<div class="notice${bad ? " bad" : ""}">${esc(msg)}</div>` : ""; }+// ---------------------------------------------------------------- identity+// 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.+const ident = {+ ok: null, key: null, audience: null, sign: true,+ async init() {+ this.ok = await PyrtanIdentity.available();+ if (this.ok) { try { this.key = await PyrtanIdentity.store.get(); } catch (e) { this.key = null; } }+ try { this.sign = localStorage.getItem("pyrtan.identity.sign") !== "off"; } catch (e) {}+ this.render();+ },+ 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 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();+ },+ 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; }+ 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();+ },+ 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;+ await PyrtanIdentity.store.put(null); this.key = null; this.render();+ },+ render() {+ if (!$("#tab-identity")) return;+ $("#ident-none").hidden = this.ok !== false;+ $("#ident-off").hidden = !this.ok || !!this.key;+ $("#ident-on").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;+ },+};+$("#ident-make").addEventListener("click", () => ident.make().catch((e) => problem("Could not make a key: " + e.message, true)));+$("#ident-note-form").addEventListener("submit", (e) => { e.preventDefault(); ident.useNote($("#ident-note").value.trim()); });+$("#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) {} });+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;@@ -298,6 +388,7 @@ function showTab(name) {if (name === "unanswered") loadUnanswered(); if (name === "directory") loadDirectory(); if (name === "member") member.render();+ if (name === "identity") ident.render();if (name === "chat") chat.count(); }@@ -362,6 +453,8 @@ async function load() {$("#peers-tab").hidden = !isHub; $("#directory-tab").hidden = !(isHub && a.federation); $("#member-tab").hidden = !(isHub && a.federation);+ ident.audience = a.audience || null;+ $("#identity-tab").hidden = !(isHub && a.audience);$("#chat-tab").hidden = !(isHub && a.chat); member.hub = a.federation || null; if (member.hub) member.start();@@ -501,27 +594,10 @@ $("#connect-form").addEventListener("submit", (e) => {// ---------------------------------------------------------------- member // A browser as a federation actor without an inbox. Everything it sends is // signed the way hubs sign: HMAC-SHA256 over method, path, date and the-// SHA-256 of the canonical JSON body. SHA-256 and HMAC are written out here-// because crypto.subtle is not available on a plain http:// LAN address.-const sha256 = (() => {- const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];- const rotr = (x, n) => (x >>> n) | (x << (32 - n));- return (bytes) => {- const l = bytes.length, bitLen = l * 8, padded = new Uint8Array(((l + 9 + 63) >> 6) << 6);- padded.set(bytes); padded[l] = 0x80;- const dv = new DataView(padded.buffer); dv.setUint32(padded.length - 4, bitLen >>> 0); dv.setUint32(padded.length - 8, Math.floor(bitLen / 4294967296));- let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];- const w = new Uint32Array(64);- for (let o = 0; o < padded.length; o += 64) {- for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + i * 4);- for (let i = 16; i < 64; i++) { const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3), s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10); w[i] = (w[i-16] + s0 + w[i-7] + s1) >>> 0; }- let [a,b,c,d,e,f,g,hh] = h;- for (let i = 0; i < 64; i++) { const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25), ch = (e & f) ^ (~e & g), t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0, S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22), maj = (a & b) ^ (a & c) ^ (b & c), t2 = (S0 + maj) >>> 0; hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; }- h = h.map((v, i) => (v + [a,b,c,d,e,f,g,hh][i]) >>> 0);- }- return new Uint8Array(h.flatMap(v => [v >>> 24 & 255, v >>> 16 & 255, v >>> 8 & 255, v & 255]));- };-})();+// SHA-256 of the canonical JSON body. HMAC is written out here (and SHA-256+// in identity.js) because crypto.subtle is not available on a plain http://+// LAN address. With a key (the Identity tab), it follows as that key.+const {sha256, canonical} = PyrtanIdentity;const enc = new TextEncoder(); const hex = (b) => Array.from(b, x => x.toString(16).padStart(2, "0")).join(""); function hmac(keyText, msgBytes) {@@ -531,11 +607,6 @@ function hmac(keyText, msgBytes) {const inner = sha256(new Uint8Array([...ipad, ...msgBytes])); return hex(sha256(new Uint8Array([...opad, ...inner]))); }-function canonical(v) {- if (v === null || typeof v !== "object") return JSON.stringify(v);- if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";- return "{" + Object.keys(v).sort().map(k => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";-}function signHeaders(secret, actor, method, path, body) { const date = String(Math.floor(Date.now() / 1000)); const digest = hex(sha256(enc.encode(body === null || body === undefined ? "" : canonical(body))));@@ -569,15 +640,17 @@ const member = {}, activity(type, object, extra) { return Object.assign({type, id: this.me.id + "/activities/" + randomHex(8), actor: this.me.id, published: new Date().toISOString().replace(/\.\d+Z$/, "Z"), object}, extra || {}); }, async join(name, fedSecret) {- this.me = {id: "urn:pyrtan:member:" + randomHex(16), secret: randomHex(32), name: name || "browser", state: "pending", listed: true, joined: Date.now(), trusted: false};+ const keyed = ident.active();+ this.me = {id: keyed ? ident.key.id : "urn:pyrtan:member:" + randomHex(16), secret: randomHex(32), name: name || "browser", state: "pending", listed: true, joined: Date.now(), trusted: false};this.save(); const act = this.activity("Follow", this.hub, {secret: this.me.secret, inbox: "mailbox", name: this.me.name}); const headers = Object.assign({"Accept": "application/json", "Content-Type": "application/json"}, signHeaders(fedSecret || this.me.secret, this.me.id, "POST", "/inbox", act));+ if (keyed) Object.assign(headers, await ident.headers("POST", "/inbox", act)); // a hub that grants this key, or its root, run makes it trustedif (token) headers["Authorization"] = "Bearer " + token; // the operator's token makes this device trusted: it runs the hub's trees const r = await fetch((base ? base.replace(/\/+$/, "") : "") + "/inbox", {method: "POST", headers, body: canonical(act)}); const data = await r.json().catch(() => ({})); if (r.status !== 202) { this.me = null; this.save(); this.note(`follow refused: ${data.say || r.status}`); problem(`The hub did not take the follow: ${data.say || r.status}`, true); this.render(); return; }- this.me.trusted = !!data.trusted; this.save();+ this.me.trusted = !!data.trusted; this.me.identity = data.identity || null; this.save();this.note(`followed ${this.hub}` + (this.me.trusted ? " as a trusted device" : "")); await this.poll(); this.start();@@ -654,7 +727,7 @@ const member = {$("#member-id").textContent = this.me.id; $("#member-state").textContent = this.me.state; $("#member-name-shown").textContent = this.me.name; $("#member-polled").textContent = this.me.polled ? new Date(this.me.polled).toLocaleTimeString() : "never"; $("#member-listed").checked = !!this.me.listed;- $("#member-trust").textContent = this.me.trusted ? "trusted: runs the hub's trees" : "keeper only (join with the hub's token to run trees)";+ $("#member-trust").textContent = this.me.trusted ? "trusted: runs the hub's trees" + (this.me.identity ? ` (as ${PyrtanIdentity.short(this.me.identity)})` : "") : "keeper only (join with the hub's token, or as a key it grants run, to run trees)";const tb = $("#member-trees tbody"); tb.innerHTML = ""; const sel = $("#member-run-tree"); const chosen = sel.value; sel.innerHTML = ""; for (const t of this.capabilities()) {@@ -765,11 +838,11 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tabwindow.addEventListener("pagehide", () => chat.leave(true)); $("#forget").addEventListener("click", () => { store.clear(); base = ""; token = ""; $("#hub-url").value = ""; $("#hub-token").value = ""; $("#who").textContent = "not connected"; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); });-load();+ident.init().finally(load);</script> </body> </html>-""".replace("__VERSION__", __version__).replace("__THEME__", THEME_JS)+""".replace("__VERSION__", __version__).replace("__THEME__", THEME_JS).replace("__IDENTITY__", IDENTITY_JS)MANIFEST = json.dumps({ "name": "Pyrtan", diff --git a/tests/browser/console_identity.cjs b/tests/browser/console_identity.cjs new file mode 100644 index 0000000..40fff59 --- /dev/null +++ b/tests/browser/console_identity.cjs@@ -0,0 +1,46 @@+// The console's Identity tab, driven in Chromium against a running hub.+// node tests/browser/console_identity.cjs HUB_URL+// Prints `DEVICE <id>` once the page has made its key, then reads one line+// from stdin (the device note the test's root signs for that id), pastes it,+// asks a sentence and joins as a member, printing `ASKED <say>` and+// `JOINED <what the member tab says>`. Needs node's playwright and a Chromium.+const { chromium } = require("playwright");+const readline = require("readline");+const hub = process.argv[2];+const line = () => new Promise((res) => { const rl = readline.createInterface({ input: process.stdin }); rl.once("line", (l) => { rl.close(); res(l); }); });++(async () => {+ const browser = await chromium.launch(process.env.PYRTAN_BROWSER ? { executablePath: process.env.PYRTAN_BROWSER } : {});+ try {+ const page = await browser.newPage();+ page.on("pageerror", (e) => console.log("PAGEERROR " + e.message));+ await page.goto(hub + "/");+ await page.waitForSelector("#identity-tab:not([hidden])");+ await page.click("#identity-tab");+ await page.click("#ident-make");+ await page.waitForFunction(() => document.querySelector("#ident-id").textContent.startsWith("pyr:"));+ console.log("DEVICE " + await page.textContent("#ident-id"));+ const note = await line();+ await page.fill("#ident-note", note);+ await page.click("#ident-note-form button");+ await page.waitForFunction(() => document.querySelector("#ident-paired").textContent.includes("speaks for"));+ console.log("PAIRED " + await page.textContent("#ident-paired"));+ // Survives a reload: the key is in IndexedDB, non-extractable.+ await page.reload();+ await page.waitForSelector("#identity-tab:not([hidden])");+ await page.click("#identity-tab");+ await page.waitForFunction(() => document.querySelector("#ident-paired").textContent.includes("speaks for"));+ console.log("KEPT " + await page.textContent("#ident-id"));+ await page.click('#tabs a[data-tab="ask"]');+ await page.fill("#text", "echo from the browser");+ await page.click("#go");+ console.log("ASKED " + await (await page.waitForSelector("#out .card .say, #out .card .status.error")).textContent());+ await page.click("#member-tab");+ await page.fill("#member-name", "browser");+ await page.click("#join-form button");+ await page.waitForFunction(() => !document.querySelector("#member-on").hidden);+ console.log("JOINED " + await page.textContent("#member-id") + " " + await page.textContent("#member-trust"));+ } finally {+ await browser.close();+ }+})().catch((e) => { console.log("ERROR " + e.message); process.exit(1); });diff --git a/tests/conformance/identity.cjs b/tests/conformance/identity.cjs new file mode 100644 index 0000000..3ddb431 --- /dev/null +++ b/tests/conformance/identity.cjs@@ -0,0 +1,38 @@+// Run identity.json through identity.js and compare with what Python said.+// node tests/conformance/identity.cjs (exit 1 on any difference)+const fs = require("fs");+const path = require("path");+const ROOT = path.resolve(__dirname, "..", "..");+const Id = require(path.join(ROOT, "src", "pyrtan", "js", "identity.js"));+const v = JSON.parse(fs.readFileSync(path.join(__dirname, "identity.json"), "utf8"));+const bytes = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16));++(async () => {+ let failed = 0, total = 0;+ const check = (label, python, js) => {+ total++;+ const a = Id.canonical(python === undefined ? null : python), b = Id.canonical(js === undefined ? null : js);+ if (a === b) { console.log(`ok ${label}`); return; }+ failed++;+ console.log(`DIFF ${label}\n python: ${a.slice(0, 400)}\n js: ${b.slice(0, 400)}`);+ };+ for (const s of v.sha3_256) check(`sha3-256 of ${s.in.length / 2} bytes`, s.out, Id.hex(Id.sha3_256(bytes(s.in))));+ for (const i of v.ids) {+ check(`id of ${i.key.slice(0, 8)}…`, i.id, Id.encode(bytes(i.key)));+ check(`key of ${Id.short(i.id)}`, i.key, Id.hex(Id.decode(i.id)));+ }+ for (const t of v.refuse) check(`refuses ${t.slice(0, 24)}…`, false, Id.valid(t));+ for (const t of v.accept) check(`accepts ${t.slice(0, 24)}…`, true, Id.valid(t));+ for (const c of v.canonical) check(`canonical ${c.canonical.slice(0, 40)}`, c.canonical, Id.canonical(c.value));+ check("a pasted note: good", null, Id.noteProblem(v.note, v.note.device, v.note.issued));+ check("a pasted note: another key's", true, /not this browser's key/.test(Id.noteProblem(v.note, v.ids[2].id, v.note.issued)));+ check("a pasted note: expired", "the note has expired", Id.noteProblem(v.note, v.note.device, v.note.expires + 1));+ for (const r of v.requests) {+ const label = `${r.method} ${r.path}${r.note ? " (device)" : ""}`;+ check(`message ${label}`, r.message, new TextDecoder().decode(Id.requestMessage(r.audience, r.method, r.path, r.date, r.body)));+ const 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));+ }+ console.log(`${total - failed}/${total} agree`);+ process.exit(failed ? 1 : 0);+})();diff --git a/tests/conformance/identity.json b/tests/conformance/identity.json new file mode 100644 index 0000000..d24e299 --- /dev/null +++ b/tests/conformance/identity.json@@ -0,0 +1,245 @@+{+ "accept": [+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb",+ "PYR:H3RKRJZIHSZP24UJIPNKCJ7PBHSIGBY2RNF4NGN2IURPBGYUZ7PGYOYB"+ ],+ "canonical": [+ {+ "canonical": "null",+ "value": null+ },+ {+ "canonical": "true",+ "value": true+ },+ {+ "canonical": "0",+ "value": 0+ },+ {+ "canonical": "-12",+ "value": -12+ },+ {+ "canonical": "12345678901",+ "value": 12345678901+ },+ {+ "canonical": "\"\"",+ "value": ""+ },+ {+ "canonical": "\"plain\"",+ "value": "plain"+ },+ {+ "canonical": "\"é ü 日本 🙂\"",+ "value": "é ü 日本 🙂"+ },+ {+ "canonical": "\"quote \\\" back \\\\ slash\\nline\"",+ "value": "quote \" back \\ slash\nline"+ },+ {+ "canonical": "[]",+ "value": []+ },+ {+ "canonical": "{}",+ "value": {}+ },+ {+ "canonical": "[1,\"two\",[3]]",+ "value": [+ 1,+ "two",+ [+ 3+ ]+ ]+ },+ {+ "canonical": "{\"a\":{\"c\":null,\"d\":[]},\"b\":1}",+ "value": {+ "a": {+ "c": null,+ "d": []+ },+ "b": 1+ }+ },+ {+ "canonical": "{\"text\":\"echo hi\",\"trace\":true}",+ "value": {+ "text": "echo hi",+ "trace": true+ }+ }+ ],+ "ids": [+ {+ "id": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"+ },+ {+ "id": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "key": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"+ },+ {+ "id": "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb",+ "key": "3ee2a8a7283cb2fd728943daa127ef09e483071a8b4bc699ba4522f09b14cfde"+ }+ ],+ "note": {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ },+ "refuse": [+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoy",+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyba",+ "pyx:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb",+ "pyr:h3rkrj1ihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb",+ "pyr:h3rkrjzihszp24ujbpnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoyb",+ "pyr:h3rkrjzihszp24ujipnkcj7pbhsigby2rnf4ngn2iurpbgyuz7pgyoaa"+ ],+ "requests": [+ {+ "audience": "https://hub.example/",+ "body": {+ "text": "echo hi"+ },+ "date": 1793000000,+ "headers": {+ "X-Pyrtan-Date": "1793000000",+ "X-Pyrtan-Key": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "X-Pyrtan-Key-Signature": "FVcGwg8YxUaItpxD3ALJuZL1Uamf9adrUlOkQDUAFLr4_tvHKEgvza_0JcY8XOXdZN0JQK9OH2rcr9Xvd8zTDg"+ },+ "message": "pyrtan-req-v1\nhttps://hub.example\nPOST\n/intent\n1793000000\nce589f747ff772b9aecfb4bafcd8bb94493ab00a4bf7feffa4f6dd6e24677a3d",+ "method": "POST",+ "note": null,+ "path": "/intent",+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ },+ {+ "audience": "https://hub.example/",+ "body": null,+ "date": 1793000000,+ "headers": {+ "X-Pyrtan-Date": "1793000000",+ "X-Pyrtan-Key": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "X-Pyrtan-Key-Signature": "9vtzBA9xhTlCJOKS_60_BRyX3BA38S_v42pOgSi09SnnB74kaKjDcAv1QMBR0kWKyKaLhkBvFfD_ufWUzC4WBQ"+ },+ "message": "pyrtan-req-v1\nhttps://hub.example\nGET\n/capabilities\n1793000000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",+ "method": "GET",+ "note": null,+ "path": "/capabilities",+ "public": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",+ "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"+ },+ {+ "audience": "https://hub.example/",+ "body": {+ "interests": [+ "disk"+ ],+ "want": "bot"+ },+ "date": 1793000000,+ "headers": {+ "X-Pyrtan-Date": "1793000000",+ "X-Pyrtan-Device": "eyJkZXZpY2UiOiJweXI6aHZhYnBxN2lpb2V2dmV2eGJrdHUyZzM2eHNvanFsZ3BmM2NqbmRnYXp2azdja3h1bXlnZjJsYWIiLCJleHBpcmVzIjoxNzkzMDg2NDAwLCJpc3N1ZWQiOjE3OTI5OTk5MDAsImxhYmVsIjoiYnJvd3NlciIsIm1heSI6WyJhc2siLCJjaGF0Il0sInB5cnRhbi1kZXZpY2UiOjEsInJvb3QiOiJweXI6MjVuanFhbWN3ZWZscHZrbDczajRzemFoaGlob2M0eHQza3RjZ2pucGFpbmdyNXloa2VuYW9vYWIiLCJzaWciOiJhcnhxVEFtSk1PTFhlNW4zUTZNWE1TWnNXSV95bV9rN2ZnRXBBQVIxUmUweG1waVBYNUZBUmtXODJ0R2lwVmtSRGhCMTVaTkNZVWxITlNINmkwYlVDZyJ9",+ "X-Pyrtan-Key": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "X-Pyrtan-Key-Signature": "hKSmmVOh-U73snPh1SfQ096hPLy9igoDZ7IFM-HgLJu1JIaS-g4Idl3IMEcAIpMAixOTOpWDO9WaN8H0JotBBg"+ },+ "message": "pyrtan-req-v1\nhttps://hub.example\nPOST\n/chat/start\n1793000000\n2b7ce39f3cbf208d4b7cec0538d5abca5e9b3dda798e64397758a217658b57bd",+ "method": "POST",+ "note": {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ },+ "path": "/chat/start",+ "public": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",+ "seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"+ },+ {+ "audience": "https://hub.example/",+ "body": {+ "actor": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "inbox": "mailbox",+ "name": "café",+ "secret": "abababababababababababababababababababababababababababababababab",+ "type": "Follow"+ },+ "date": 1793000000,+ "headers": {+ "X-Pyrtan-Date": "1793000000",+ "X-Pyrtan-Device": "eyJkZXZpY2UiOiJweXI6aHZhYnBxN2lpb2V2dmV2eGJrdHUyZzM2eHNvanFsZ3BmM2NqbmRnYXp2azdja3h1bXlnZjJsYWIiLCJleHBpcmVzIjoxNzkzMDg2NDAwLCJpc3N1ZWQiOjE3OTI5OTk5MDAsImxhYmVsIjoiYnJvd3NlciIsIm1heSI6WyJhc2siLCJjaGF0Il0sInB5cnRhbi1kZXZpY2UiOjEsInJvb3QiOiJweXI6MjVuanFhbWN3ZWZscHZrbDczajRzemFoaGlob2M0eHQza3RjZ2pucGFpbmdyNXloa2VuYW9vYWIiLCJzaWciOiJhcnhxVEFtSk1PTFhlNW4zUTZNWE1TWnNXSV95bV9rN2ZnRXBBQVIxUmUweG1waVBYNUZBUmtXODJ0R2lwVmtSRGhCMTVaTkNZVWxITlNINmkwYlVDZyJ9",+ "X-Pyrtan-Key": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "X-Pyrtan-Key-Signature": "arxD-TYXAPgGMKeycTJVoGEyzHG0n4neLrDeZ077O7CLEkJ_W_NLDp9iZXcQKClAKSYAtnHBYzqs2-i_x6sZBQ"+ },+ "message": "pyrtan-req-v1\nhttps://hub.example\nPOST\n/inbox\n1793000000\n23e571b2b8b27e38e99a2b67de666f8ed1b165ccbb9a92cd9e4efd8e1ee3fd47",+ "method": "POST",+ "note": {+ "device": "pyr:hvabpq7iioevvevxbktu2g36xsojqlgpf3cjndgazvk7ckxumygf2lab",+ "expires": 1793086400,+ "issued": 1792999900,+ "label": "browser",+ "may": [+ "ask",+ "chat"+ ],+ "pyrtan-device": 1,+ "root": "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab",+ "sig": "arxqTAmJMOLXe5n3Q6MXMSZsWI_ym_k7fgEpAAR1Re0xmpiPX5FARkW82tGipVkRDhB15ZNCYUlHNSH6i0bUCg"+ },+ "path": "/inbox",+ "public": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",+ "seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"+ }+ ],+ "sha3_256": [+ {+ "in": "",+ "out": "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"+ },+ {+ "in": "616263",+ "out": "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532"+ },+ {+ "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",+ "out": "9b04c091da96b997afb8f2585d608aebe9c4a904f7d52c8f28c7e4d2dd9fba5f"+ },+ {+ "in": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878",+ "out": "c150125edc74b56fb5cbfdd024fabe20ea5a99bd3c97305bbf7cb55885c106fe"+ },+ {+ "in": "78787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878",+ "out": "5bc276bac9c582508b8fa9b3949e7ed9b6e584ee4d2925b29a426b9931ba1486"+ },+ {+ "in": "7878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878",+ "out": "2f25a6351abe05e289a0a3e65fef42db7d5fc314936bdee4f6d54d04fb20a609"+ }+ ]+}diff --git a/tests/conformance/make_identity_vectors.py b/tests/conformance/make_identity_vectors.py new file mode 100644 index 0000000..135a5bf --- /dev/null +++ b/tests/conformance/make_identity_vectors.py@@ -0,0 +1,79 @@+"""Write tests/conformance/identity.json: what both identity codes must say.++Python's src/pyrtan/identity.py says it; the console's+src/pyrtan/js/identity.js must say the same: SHA3-256 digests,+identifiers from keys, identifiers to refuse, canonical JSON, request+messages, and request headers signed with fixed keys at fixed times+(Ed25519 is deterministic, so the signatures are too).+tests/conformance/identity.cjs checks it; tests/test_conformance.py keeps+this file from drifting.++ uv run python tests/conformance/make_identity_vectors.py+"""++from __future__ import annotations++import hashlib+import json+import sys+from pathlib import Path++ROOT = Path(__file__).resolve().parents[2]+sys.path.insert(0, str(ROOT / "src"))++from pyrtan import identity # noqa: E402+from pyrtan.store import canonical # noqa: E402++DATE = 1_793_000_000+# RFC 8032 §7.1 tests 1 and 2, and a third seed of our own.+SEEDS = [+ "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",+ "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",+ "00" * 31 + "07",+]+VALUES = [+ None, True, 0, -12, 12345678901, "", "plain", "é ü 日本 🙂", "quote \" back \\ slash\nline",+ [], {}, [1, "two", [3]], {"b": 1, "a": {"d": [], "c": None}}, {"text": "echo hi", "trace": True},+]+++def build() -> dict:+ keys = [identity.Key(bytes.fromhex(s)) for s in SEEDS]+ public = [identity.decode(k.id).hex() for k in keys]+ root, phone = keys[0], keys[1]+ note = identity.make_device(root, phone.id, ["ask", "chat"], label="browser", issued=DATE - 100, expires=DATE + 86400)+ good = keys[2].id+ requests = []+ for key, note_for, method, path, body in [+ (root, None, "POST", "/intent", {"text": "echo hi"}),+ (root, None, "GET", "/capabilities", None),+ (phone, note, "POST", "/chat/start", {"want": "bot", "interests": ["disk"]}),+ (phone, note, "POST", "/inbox", {"type": "Follow", "actor": phone.id, "secret": "ab" * 32, "inbox": "mailbox", "name": "café"}),+ ]:+ audience = "https://hub.example/"+ requests.append({+ "seed": SEEDS[keys.index(key)], "public": public[keys.index(key)], "audience": audience, "method": method, "path": path, "date": DATE,+ "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),+ })+ 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],+ "ids": [{"key": p, "id": k.id} for p, k in zip(public, keys)],+ "refuse": [good[:-1], good + "a", "pyx:" + good[4:], good[:10] + "1" + good[11:], good[:20] + ("b" if good[20] != "b" else "c") + good[21:], good[:-2] + "aa"],+ "accept": [good, good.upper()],+ "canonical": [{"value": v, "canonical": canonical(v).decode("utf-8")} for v in VALUES],+ "requests": requests,+ "note": note,+ }+++def main() -> int:+ out = ROOT / "tests" / "conformance" / "identity.json"+ out.write_text(json.dumps(build(), indent=1, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")+ print(f"wrote {out}")+ return 0+++if __name__ == "__main__":+ sys.exit(main())diff --git a/tests/test_conformance.py b/tests/test_conformance.py index db27650..002810d 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py@@ -5,6 +5,7 @@ vectors file is regenerated here and compared, so it cannot drift fromthe examples or the runtime without this test saying so. """+import importlib.utilimport json import shutil import subprocess@@ -31,3 +32,23 @@ def test_javascript_runtime_agrees_with_python():run = subprocess.run(["node", str(ROOT / "tests" / "conformance" / "run.cjs")], capture_output=True, text=True, cwd=str(ROOT)) assert run.returncode == 0, run.stdout + run.stderr assert f"{len(json.loads(VECTORS.read_text())['runs'])}/" in run.stdout+++# ---------------------------------------------------------------- identity.js and identity.py++IDENTITY_VECTORS = ROOT / "tests" / "conformance" / "identity.json"+needs_crypto = pytest.mark.skipif(importlib.util.find_spec("cryptography") is None, reason="needs pyrtan[identity]")+++@needs_crypto+def test_identity_vectors_are_current():+ from make_identity_vectors import build as build_identity+ fresh = json.dumps(build_identity(), indent=1, ensure_ascii=False, sort_keys=True) + "\n"+ assert IDENTITY_VECTORS.read_text(encoding="utf-8") == fresh, "tests/conformance/identity.json is stale: run `uv run python tests/conformance/make_identity_vectors.py`"+++@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")+def test_console_identity_agrees_with_python():+ run = subprocess.run(["node", str(ROOT / "tests" / "conformance" / "identity.cjs")], capture_output=True, text=True, cwd=str(ROOT))+ assert run.returncode == 0, run.stdout + run.stderr+ assert " agree" in run.stdout and "DIFF" not in run.stdoutdiff --git a/tests/test_grants.py b/tests/test_grants.py index df1a67c..df8a433 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py@@ -297,6 +297,11 @@ def test_a_hub_with_no_token_only_asks_who(tmp_path, root):assert http_json("GET", f"{srv.url}/api")[1]["audience"] == srv.url.rstrip("/") as_root = Caller(srv.url, root) assert as_root("GET", "/peers")[0] == 200 # open hub: a key is who, not a permission+ # But nobody grants themselves anything there: that would make a trusted member, which an open hub never had.+ assert http_json("POST", f"{srv.url}/allow", {"who": root.id, "may": ["run"]})[0] == 403+ assert as_root("POST", "/allow", {"who": root.id, "may": ["run"]})[0] == 403+ h.grants.allow(root.id, ["operate"])+ assert as_root("POST", "/allow", {"who": root.id, "may": ["operate", "run"]})[0] == 200bad = identity.sign_request(root, "https://elsewhere.example", "GET", "/peers", None) assert http_json("GET", f"{srv.url}/peers", headers=bad)[0] == 401 finally:@@ -315,3 +320,56 @@ def test_hub_flags(tmp_path, root, capsys):srv.stop() with pytest.raises(IdentityError): serve_hub(port=0, quiet=True, allow=["nobody@127.0.0.1:9=ask"])+++# ---------------------------------------------------------------- the console, in a browser+++def _node_playwright() -> bool:+ import shutil+ import subprocess+ 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_holds_a_key_asks_and_joins_as_it(hub):+ """tests/browser/console_identity.cjs: the page makes a key, takes the note our root signs+ for it, keeps it across a reload, asks with no token, and joins as a trusted member."""+ import subprocess+ from conftest import ROOT+ h, srv = hub+ root = identity.Key.generate()+ h.grants.allow(root.id, ["ask", "run"])+ p = subprocess.Popen(["node", str(ROOT / "tests" / "browser" / "console_identity.cjs"), srv.url.rstrip("/")], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, cwd=str(ROOT))+ said: dict[str, str] = {}+ 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 == "DEVICE":+ p.stdin.write(json.dumps(identity.make_device(root, rest, ["ask", "chat", "run"], label="browser")) + "\n")+ p.stdin.flush()+ if word in ("JOINED", "ERROR", "PAGEERROR"):+ break+ finally:+ try:+ p.wait(timeout=30)+ except subprocess.TimeoutExpired:+ p.kill()+ assert "ERROR" not in said and "PAGEERROR" not in said, said+ device = said["DEVICE"]+ assert identity.normal(device) == device+ assert root.id in said["PAIRED"] and said["KEPT"] == device+ assert said["ASKED"] == "echo: from the browser"+ assert said["JOINED"].startswith(device) and "trusted" in said["JOINED"]+ member = h.federation.followers[device]+ assert (member.trusted, member.trusted_by, member.identity, member.device) == (True, "key", root.id, device)+++def test_a_hub_with_grants_needs_the_extra(monkeypatch, root):+ import sys+ monkeypatch.setitem(sys.modules, "cryptography.hazmat.primitives.asymmetric", None)+ with pytest.raises(IdentityError, match=r"pyrtan\[identity\]"):+ serve_hub(port=0, quiet=True, allow=[f"{root.id}=ask"])