pyrtan / sourcev0.19.0 · main

Merge pull request #24 from b-i-t-8/claude/trusting-dirac-noavo4

b-i-t-8 · 2026-10-27 · d610257a5471a161abc46a1454000e877ac58457 · parents 0a4767f ff12eb1

Identity: one key, every hub, any domain; release 0.14.0

Changes

 .github/workflows/catalog-gate.yml         |  63 +++++
 .github/workflows/catalog.yml              |  18 +-
 .github/workflows/image.yml                |   2 +-
 .github/workflows/pages.yml                |   4 +-
 CONTRIBUTING.md                            |   5 +-
 README.md                                  |  69 +++++-
 _meta/chat.md                              |  14 +-
 _meta/design.md                            |   4 +-
 _meta/identity.md                          | 495 ++++++++++++++++++++++++++++++++++++++++
 _meta/members.md                           |   6 +-
 _meta/platform.md                          |   6 +-
 catalog/README.md                          |   3 +-
 pyproject.toml                             |   3 +-
 site/README.md                             |   8 +-
 site/build.sh                              |   6 +-
 site/gate.py                               |  40 +++-
 site/names/README.md                       |  47 ++++
 src/pyrtan/__init__.py                     |   2 +-
 src/pyrtan/cli.py                          | 448 +++++++++++++++++++++++++++++++++---
 src/pyrtan/federation.py                   |  62 +++--
 src/pyrtan/grants.py                       | 251 ++++++++++++++++++++
 src/pyrtan/hub.py                          | 136 +++++++++--
 src/pyrtan/identity.py                     | 605 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/pyrtan/js/identity.js                  | 184 +++++++++++++++
 src/pyrtan/names.py                        | 304 +++++++++++++++++++++++++
 src/pyrtan/page.py                         | 137 ++++++++---
 src/pyrtan/service.py                      |  24 +-
 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                       | 392 ++++++++++++++++++++++++++++++++
 tests/test_identity.py                     | 318 ++++++++++++++++++++++++++
 tests/test_names.py                        | 271 ++++++++++++++++++++++
 uv.lock                                    | 169 +++++++++++++-
 36 files changed, 4373 insertions(+), 152 deletions(-)
diff --git a/.github/workflows/catalog-gate.yml b/.github/workflows/catalog-gate.yml
new file mode 100644
index 0000000..0e152e8
--- /dev/null
+++ b/.github/workflows/catalog-gate.yml
@@ -0,0 +1,63 @@
+# The catalog gate, run from main: every pull request that touches a tree
+# or a name is judged by main's site/gate.py, main's pyrtan and main's
+# lockfile, against the pull request's tree read as data. Which files
+# changed, who opened the pull request and what each file was on main
+# come from git and the event: a change must come from a namespace's
+# owners and bump its version; a new name is the author's own login with
+# the author's numeric GitHub id, and only that account changes it.
+#
+# pull_request_target, because under pull_request the workflow itself is
+# the pull request's and could be edited to run its own gate. Nothing from
+# the pull request is ever executed here: it is checked out beside main,
+# read by main's code, with a read-only token, no secrets, no credentials
+# left in either checkout and no cache. Keep it that way: never add a step
+# that runs, installs or imports anything from pr/.
+# A maintainer merges when it is green; the pages workflow publishes on merge.
+
+name: catalog-gate
+
+on:
+ pull_request_target:
+ paths:
+ - "catalog/**"
+ - "examples/**"
+ - "site/gate.py"
+ - "src/pyrtan/catalog.py"
+ - "site/names/**"
+ - "src/pyrtan/names.py"
+ - "src/pyrtan/identity.py"
+
+permissions:
+ contents: read
+
+jobs:
+ gate:
+ runs-on: ubuntu-latest
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ AUTHOR: ${{ github.event.pull_request.user.login }}
+ AUTHOR_ID: ${{ github.event.pull_request.user.id }}
+ steps:
+ # The judge: main as the pull request found it.
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.base.sha }}
+ path: base
+ fetch-depth: 0
+ persist-credentials: false
+ # What is judged: the pull request's tree, as data.
+ - 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: Give the pull request's checkout main's commit, to diff against
+ run: git -C pr fetch --no-tags "$GITHUB_WORKSPACE/base" "$BASE_SHA"
+ - uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: false
+ - run: uv sync --project base --frozen --extra identity
+ - run: uv run --project base --no-sync pyrtan check pr/examples pr/catalog --quiet
+ - run: uv run --project base --no-sync python base/site/gate.py --root pr --base "$BASE_SHA" --author "$AUTHOR" --author-id "$AUTHOR_ID"
diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml index d1f16e9..c1ffc2d 100644 --- a/.github/workflows/catalog.yml +++ b/.github/workflows/catalog.yml
@@ -1,8 +1,8 @@
-# The catalog gate: every pull request that touches a tree runs the same
-# checks `pyrtan publish` ran locally, with what git knows added: which
-# files changed, who opened the pull request, and what version each tree
-# had on main. A maintainer merges when it is green; the pages workflow
-# publishes on merge.
+# The catalog tests: every pull request that touches a tree, a name or the
+# gate runs the test suite and `pyrtan check` on its own code. The gate
+# itself, which decides whether the pull request may change what it
+# changes, runs from main in the catalog-gate workflow, so a pull request
+# cannot change the gate that judges it.
name: catalog
@@ -13,19 +13,21 @@ on:
- "examples/**" - "site/gate.py" - "src/pyrtan/catalog.py"
+ - "site/names/**"
+ - "src/pyrtan/names.py"
+ - "src/pyrtan/identity.py"
permissions: contents: read jobs:
- gate:
+ tests:
runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: astral-sh/setup-uv@v5
- - run: uv sync
+ - run: uv sync --extra identity # the identity tests sign and verify
- run: uv run pytest -q - run: uv run pyrtan check examples catalog --quiet
- - run: uv run python site/gate.py --base "origin/${{ github.base_ref }}" --author "${{ github.event.pull_request.user.login }}"
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 7ead3a1..f9ea120 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml
@@ -20,7 +20,7 @@ jobs:
steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5
- - run: uv sync
+ - run: uv sync --extra identity # the identity tests sign and verify
- run: uv run pytest -q - run: uv run pyrtan check examples diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 977a587..2f580f7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml
@@ -34,9 +34,11 @@ jobs:
with: fetch-depth: 0 # every branch and the whole history, for /source - uses: astral-sh/setup-uv@v5
- - run: uv sync
+ - run: uv sync --extra identity # the identity tests sign and verify
- run: uv run pytest -q - run: uv run site/build.sh out
+ # v3, not v4: v4 leaves out dot-directories, and the names are served
+ # from .well-known/pyrtan/.
- uses: actions/upload-pages-artifact@v3 with: path: out diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69321da..c90117c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md
@@ -33,8 +33,9 @@ namespace. Everyone else publishes under `catalog/<namespace>/`:
pyrtan publish my-tree.toml --into . --owner <your GitHub login> git checkout -b catalog/<namespace>-my-tree && git add catalog && git commit && git push -u origin HEAD
-then open a pull request. The `catalog` workflow runs the gate
-(`site/gate.py`): the tree loads clean, has a licence and a namespace,
+then open a pull request. The `catalog-gate` workflow runs main's copy
+of the gate (`site/gate.py`), so a change to the gate is judged by the
+gate it changes only once it is merged: the tree loads clean, has a licence and a namespace,
sits in its namespace's directory under its own name, no other tree has its id, only the namespace's `OWNERS` change it, and a changed file bumps its `version`. A test and a recording are welcome but not required for diff --git a/README.md b/README.md index 746f95a..623515b 100644 --- a/README.md +++ b/README.md
@@ -55,6 +55,8 @@ pyrtan --version
Trees that drive a browser need the one optional extra, Playwright: `pip install 'pyrtan[browser]'` and `playwright install chromium` (or point `PYRTAN_BROWSER` at a Chromium you have).
+Signing and checking identities (`pyrtan id`) needs the other,
+`cryptography`: `pip install 'pyrtan[identity]'`.
From a checkout, `uv sync` then `uv run pyrtan …`, and `uv run pytest`.
@@ -174,6 +176,9 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan pull NAME[@VERSION]` | Fetches a tree from a catalog into your trees folder, verified against the catalog's index. | | `pyrtan index DIR` | Builds a catalog `index.json` from a directory of trees, and `trees.json` beside it (the same trees as JSON, for the console in a browser); what a catalog's CI runs. | | `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 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 `Authorization: Bearer` on everything but `/health` and the console, and
@@ -314,6 +319,54 @@ sentence carries `via: [the hub it crossed]`, and the console's Peers tab
shows who follows whom. The design is in [`_meta/federation.md`](_meta/federation.md).
+## Identity
+
+A person, a hub or a device is a key, and its identity is that key
+written out: `pyr:` and 56 characters, which anyone can check a
+signature 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 keeps its own list of who may do what.
+
+```
+pyrtan id new # your root key, ~/.config/pyrtan/identity/
+pyrtan 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
+```
+
+`pyrtan id resolve alice@pyrtan.com` fetches the claim the domain serves
+at `/.well-known/pyrtan/name/alice.json`, checks that the key signed it,
+and pins the answer: if the domain later names a different key, you are
+told rather than switched. Any domain can serve names (`pyrtan id
+wellknown DIR --domain D` builds the files). pyrtan.com hands out
+`<your GitHub login>@pyrtan.com` by pull request: `pyrtan id name
+<login>@pyrtan.com --into <checkout>`, then a pull request from that
+account, and the gate checks the account's numeric id
+([`site/names/README.md`](site/names/README.md)).
+
+**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 Trees other people wrote are at <https://pyrtan.com/catalog/>, a directory
@@ -340,7 +393,7 @@ identifier such as `MIT`) and who publishes it in a `namespace` line.
<checkout of this repository> --owner <your GitHub login>` puts the file under `catalog/<namespace>/`, writes the namespace's `OWNERS` if it is new, and runs the gate; push the branch and open a pull request. The
-`catalog` workflow runs the same gate (every tree loads clean with a
+`catalog-gate` workflow runs main's copy of the same gate (every tree loads clean with a
licence and a namespace, sits where its namespace and name say, ids are unique, only a namespace's owners change it, a changed file bumps its `version`), a maintainer merges, and the next build serves it. No
@@ -367,6 +420,8 @@ pyrtan serve trees --hub http://hub:8700 --token … --gaps /var/lib/pyrtan/gaps
A 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
@@ -459,7 +514,8 @@ secret; the outbox logs a `Say` without its text and leaves `Typing` out. A hub
hub 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). To let
+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`). To let
strangers in and keep the token on everything else, start the hub `--chat-public`: callers without the token are guests, who meet people (here, or on a hub this one follows) and never a bot. There is no chat
@@ -573,7 +629,8 @@ 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, page (the console), catalog, cli
+ 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 tests/ pytest; a swarm of two services behind a hub runs on localhost
@@ -588,7 +645,7 @@ site/ pyrtan.com: the landing page, the hub list, the catalog
## Status
-0.13.0. The core, the service, the hub, the command, the console, the
+0.14.0. The core, the service, the hub, the command, the console, the
catalog (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; pyrtan.com publishes the
@@ -601,7 +658,9 @@ topic or a serial line speaks), `pyrtan grow` (the branch a gap asks
for, drafted by rules), and the chat lobby (strangers on the hub and the hubs it follows, bots made of trees, a typing indicator, and lobbies open to the public) 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. Not yet built: hosting.
+and its own box, and follow other swarms. Identities (keys, device notes,
+name claims, names at pyrtan.com and pins, grants on hubs, the console's
+key, `pyrtan id` and `pyrtan allow`) are built. Not yet built: hosting.
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 f3c7214..a1ec497 100644 --- a/_meta/chat.md +++ b/_meta/chat.md
@@ -35,18 +35,24 @@ the hub's order. A seat nobody polls for a minute is let go and its
partner 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 public lobby (v0.13).** A hub that wants strangers from the internet in its lobby, and its token on everything else, starts `--chat-public` (`$PYRTAN_CHAT_PUBLIC`). `/chat` then answers without
-the token, and a caller without it is a *guest*: a guest meets people,
+the token, and a caller without it (or without a key granted `chat`)
+is a *guest*: a guest meets people,
on this hub or on a hub it follows, and never a bot. `want: bot` from a guest is refused (`bots-need-token`), `anyone` is read as `people`, and `/chat` counts no bots for them. `/api` says `chat_public` and, for the caller asking, `chat_bots`; the console offers a guest "people only" and
-says why. The rest of the hub (`/intent`, `/run`, `/gaps`, `/follow`,
+says why. A request signed with a key the hub grants nothing is a
+guest too, not refused: a browser that holds a key is not shut out of a
+lobby open to everyone. The rest of the hub (`/intent`, `/run`, `/gaps`, `/follow`,
`/keep`) still needs the token, so opening the lobby lends strangers nothing but each other. A hub with no token has no guests: there, anyone may meet a bot, as anyone may ask it at `/intent`. diff --git a/_meta/design.md b/_meta/design.md index b794e0d..e7adb0e 100644 --- a/_meta/design.md +++ b/_meta/design.md
@@ -331,7 +331,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.14 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 new file mode 100644 index 0000000..053b94f --- /dev/null +++ b/_meta/identity.md
@@ -0,0 +1,495 @@
+# Design — Identity: one key, every hub, any domain
+
+| | |
+|---|---|
+| **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*. |
+| **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`. |
+
+## 0. The brief
+
+*"Can we create universal users for the whole pyrtan network with just
+our GitHub pages? i.e. hash_id:pyrtan.com or other domains, onion routes
+etc., basically all key based."*
+
+Read as: someone should be one person across every hub they use, without
+an account on any of them and without pyrtan.com running a server; the
+part that identifies them should be a key, so that no domain owns them;
+and a domain (pyrtan.com, their own, an onion service) should be a place
+to find them and a name for them, not the identity itself.
+
+Today there is no such thing. A hub's identity is its URL. A member's is
+`urn:pyrtan:member:<hex>`, made fresh by each browser for each hub, with
+an HMAC pair secret only that hub knows (`members.md` §2). Being trusted
+means having been given the hub's bearer token, which is one secret for
+the whole swarm (`design.md` §7). Chat has no names at all (`chat.md`
+§4). Each of those was right for what it was built for; none of them
+lets a hub say "Alice may ask, and Alice's phone may run trees" without
+handing Alice the token.
+
+## 1. What this design refuses
+
+- **An account anywhere.** No hub, and not pyrtan.com, holds a user
+ record that the user's identity depends on. A key is made on the
+ person's own machine and never leaves it.
+- **A domain that owns a person.** `user@server` identifiers (email,
+ Matrix, ActivityPub) belong to whoever runs the server; lose the
+ server and you are someone else. Here a domain can stop vouching for a
+ name, and the identity is untouched.
+- **pyrtan.com as the authority.** pyrtan.com is static files (`platform.md`).
+ It can publish names; it cannot log anyone in, and the network must
+ work for an identity pyrtan.com has never heard of.
+- **Identity as a grant.** A valid signature proves who is asking. What
+ they may do is the hub's own list (§7), the same way following is the
+ one act of trust between hubs (`federation.md` §1). There is no
+ network-wide permission.
+- **Passwords, recovery codes, and recovery by anyone but the holder.**
+ A lost root key is a lost identity (§9). Recovery by a third party is
+ a third party who can become you.
+- **Making anything that works today need an identity.** HMAC follows,
+ networks, the token, anonymous members and anonymous chat all stay as
+ they are. Identity is opt-in at each end.
+- **This author's own signing code.** A private key is handled only by
+ a maintained library or the browser (§8).
+
+## 2. The identifier
+
+An identity is an Ed25519 public key, 32 bytes, written as:
+
+```
+pyr:<base32( key || checksum || version )>
+checksum = SHA3-256( ".pyrtan id" || key || version )[:2]
+version = 0x01 (Ed25519)
+base32 = RFC 4648, lowercase, no padding
+```
+
+That is 35 bytes, 56 characters after the prefix. The layout is Tor's
+v3 onion address with Pyrtan's own checksum string and version, so it
+is a known shape, and base32 and SHA3-256 are both in the standard
+library: encoding and checking an identifier needs no dependency, only
+signatures do. A vector, from RFC 8032 §7.1 test 1's public key
+`d75a9801…f707511a`:
+
+```
+pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab
+```
+
+Rules:
+
+- **Compare the whole string,** after lowercasing. Nothing shorter is an
+ identifier. A display may shorten to `pyr:25nj…ooab`; a program never
+ matches on that.
+- **Refuse on a bad checksum,** a version other than one it knows, or a
+ length other than 56. A typo is caught before any network call.
+- **The key, not a hash of it.** For Ed25519 the two are the same length,
+ and the key lets a verifier check a signature with nothing else in
+ hand. Agility comes from the version byte: another algorithm is another
+ version, with its own length.
+- **It is a DNS label.** 56 lowercase base32 characters fit in 63, so an
+ identifier can stand where a host name part does, should that ever be
+ wanted. Nothing in this design depends on it.
+- **Not an onion address.** The checksum string differs on purpose, so a
+ `pyr:` identifier never parses as a `.onion` and back. An identity that
+ is reachable over Tor says so as a route (§3), with the onion service's
+ own key; one key for two jobs is how a compromise of one becomes both.
+
+An identity is a **root** (a person or a hub), or a **device** (a key a
+root has delegated to, §5). Both are written the same way; which one a
+key is depends on the documents around it, not on the string.
+
+## 3. Three forms
+
+```
+identity = "pyr:" key56
+address = identity "@" locator
+locator = host [ ":" port ] ; a DNS name, an .onion, or [ip literal]
+name = local "@" domain
+local = [a-z0-9] [a-z0-9._-]{0,62}
+```
+
+A string that starts `pyr:` is an identity or an address; anything else
+with an `@` is a name. `@` and not `:` or `|`, because `:` already
+separates ports and IPv6 groups and starts URLs, and `|` is a pipe in
+every shell.
+
+- **`pyr:<key>`** is who. Everything that grants, pins or logs uses this
+ form and only this form.
+- **`pyr:<key>@hub.example`**, **`pyr:<key>@<56>.onion`** say where. A
+ locator is a host that will take messages for the identity: the hub
+ that *is* the identity, or a hub that keeps a mailbox for it as a
+ member. An identity may have any number; they are hints, carried in its
+ document (§4), and a wrong one costs a failed delivery, never a
+ forgery, because what comes back is checked against the key. A locator
+ is reached at `https://<locator>`; an `.onion` at `http://`, since Tor
+ already authenticates and encrypts to the onion's key.
+- **`alice@pyrtan.com`**, **`alice@alice.example`**,
+ **`alice@<56>.onion`** say what to call someone. A name resolves to an
+ identity through the domain (§6), and the first answer is pinned.
+
+## 4. Documents
+
+Three kinds of signed JSON, each one standing alone, so that anyone can
+hold a copy and nobody has to trust the holder:
+
+```json
+{"pyrtan-identity": 1, "id": "pyr:…", "kind": "person",
+ "routes": ["hub.example", "<56>.onion"],
+ "devices": [ <device notes, §5> ], "revoked": [ "pyr:…" ],
+ "updated": 1761580800, "sig": "…"}
+```
+
+**Signing.** `sig` is the root's Ed25519 signature over
+`"pyrtan-doc-v1\n"` followed by the document without `sig`, as the
+canonical JSON hubs already sign (`federation.py`, `canonical()`: sorted
+keys, no spaces, UTF-8). Encoded base64url without padding. Documents
+hold strings, integers, lists and objects, never floats, so the Python
+and JavaScript runtimes produce the same bytes. The domain string means
+a document signature can never be replayed as a request signature (§7)
+or the other way round.
+
+**Which copy wins.** The highest `updated` among copies whose signature
+holds. A copy from the future (more than the signature window, five
+minutes, ahead) is refused rather than clamped: unlike a directory entry,
+a document that wins forever would be a way to freeze revocation.
+
+**Where copies live.** Any host may serve
+`/.well-known/pyrtan/id/<56>.json`: the identity's own hub, the hubs it is
+a member of, pyrtan.com for an identity with a name there (§6). An
+identity document is not secret, so it is a fine blob (`members.md` §3):
+a hub that has granted an identity keeps its document and hands it to its
+keepers like any other.
+
+`kind` is `person` or `hub`. It is what the identity says of itself, for
+display; nothing branches on it.
+
+## 5. Devices
+
+The root key should live somewhere quiet: a file on one machine, mode
+600, or offline. Each place the person actually uses (a laptop's CLI, a
+phone's console) gets its own key, and the root signs a note for it:
+
+```json
+{"pyrtan-device": 1, "root": "pyr:…", "device": "pyr:…",
+ "label": "phone", "may": ["ask", "chat", "run"],
+ "issued": 1761580800, "expires": 1793116800, "sig": "<root's>"}
+```
+
+- **`may`** caps what the device can ever be granted; a hub grants the
+ intersection of its own list and this (§7). A phone that only chats
+ says `["chat"]`, and stealing it gets nobody further.
+- **`expires`** is at most a year after `issued`. A note past it is
+ refused; the root renews by signing a new one.
+- **Revocation** is the device id in the identity document's `revoked`,
+ under a newer `updated`. A hub that holds the document re-reads it
+ (from 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'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
+
+`alice@example.org` resolves by fetching
+
+```
+https://example.org/.well-known/pyrtan/name/alice.json
+```
+
+which is a claim signed by the identity it names:
+
+```json
+{"pyrtan-name": 1, "name": "alice", "domain": "example.org",
+ "id": "pyr:…", "issued": 1761580800, "sig": "<id's>"}
+```
+
+The resolver checks that `name` and `domain` are what it asked for and
+that `sig` holds under `id`. The self-signature is what stops a domain,
+or someone registering at it, from pointing a name at a key whose holder
+never claimed it. The domain's part is serving the file; the key's part
+is signing it; both have to agree.
+
+`pyrtan id name alice@example.org` signs a claim with your root key, for
+the domain to serve.
+
+**Pinning.** The first identity a name resolves to is remembered (in the
+hub's state file, the console's storage, `pins.json` beside the CLI's
+key). `pyrtan id resolve alice@example.org` resolves, pins and shows the
+identity document if the domain serves one; `--repin` takes a changed
+answer; `pyrtan id pins` lists them and `--forget` drops one. A later
+answer naming a different identity is not followed: it is shown beside
+the pinned one, and the operator re-pins by hand. This is SSH's
+known-hosts rule, and it is what makes a domain's word safe to take:
+pyrtan.com, or anyone, can remap a name, and every client that saw the
+old answer notices.
+
+**pyrtan.com's names.** GitHub Pages serves `/.well-known/` like any other
+path and sends `Access-Control-Allow-Origin: *`, so the console can
+resolve names from a browser. The names are files in this repository,
+`site/names/<login>.json`, each a name claim with two rules only
+pyrtan.com adds, and beside it, optionally, `site/names/<login>.id.json`,
+the identity's document:
+
+- the name is the author's **GitHub login**, lowercased. One person, one
+ name, no disputes to judge, and GitHub has already checked it;
+- `"github": <numeric user id>`. GitHub frees a login when its owner
+ renames, so the gate (`site/gate.py`, main's copy, run by the
+ `catalog-gate` workflow as it is for namespaces) checks the pull request author's *numeric* id
+ (`github.event.pull_request.user.id`) against the file, not the login.
+ A new name must be the author's own login carrying the author's id; a
+ name, or its document, is changed or removed only by the account whose
+ id the claim carried on the base branch, and its `github` never
+ changes. One name per account. To follow a rename, remove the old name
+ and add the new one in the same pull request. A maintainer may merge
+ over the gate to remove a name on request or for abuse.
+
+The gate also checks every claim's signature, that it is for
+`<its file's name>@pyrtan.com`, and that a document beside it is signed
+by the same key. `pyrtan id name alice@pyrtan.com --into <checkout>`
+writes both files, looking the numeric id up from the login (or taking
+`--github`), and runs the gate. The `pages` build runs `pyrtan id
+wellknown site/names --domain pyrtan.com`, which publishes each claim at
+`/.well-known/pyrtan/name/<login>.json`, an index of all of them at
+`/.well-known/pyrtan/names.json` for mirrors, and each document at
+`/.well-known/pyrtan/id/<56>.json`. A change is live within the hour,
+the build's cadence. The Pages upload stays on
+`actions/upload-pages-artifact@v3`: v4 leaves out dot-directories.
+
+**Other domains** do the same by serving the same paths, and `pyrtan id
+wellknown DIR --domain D --out SITE` builds them from a directory of
+claims; nothing about them is pyrtan.com's business. An operator can serve names from their own
+hub. An onion service can serve names to anyone who fetches over Tor.
+
+## 7. On the wire
+
+**Signed requests.** A request made as an identity carries, beside or
+instead of the HMAC headers:
+
+```
+X-Pyrtan-Key: pyr:… the signing key (a device or a root)
+X-Pyrtan-Date: 1761580800 as today
+X-Pyrtan-Key-Signature: <base64url> Ed25519 over the message below
+X-Pyrtan-Device: <base64url note> when the key is a device
+```
+
+```
+message = "pyrtan-req-v1\n" + audience + "\n" + METHOD + "\n" + path + "\n" + date + "\n" + sha256(canonical body)
+```
+
+which is the HMAC message (`federation.py`, `sign()`) with a domain
+string and the **audience**, the receiving hub's own `id`, in front. A
+request signed for one hub cannot be replayed at another; within one hub
+the five-minute window is the same as HMAC's. The hub checks the
+signature, the device note (signed by its root, unexpired, not revoked),
+and then its grants.
+
+The audience is the hub's federation id, or the URL it was started at
+(`--advertise`, else its own address), and `GET /api` names it, so a
+client asks once and signs for it. The body is the canonical JSON the
+request carries; a `POST` with no body is signed as `{}`, which is what
+the hub reads it as. A bad signature is refused (401) on any hub. On a
+hub with a token, a good one may do what its root is granted and nothing
+else (403 otherwise, naming what it may do); on a hub without a token,
+where anyone may do anything, a key only says who is asking, which is
+what decides whether a member it signs for is trusted.
+
+**Grants.** A hub keeps a list (`grants.json` beside its `--state`, or
+`--grants FILE`, or in memory), from `--allow` at start and from its
+operator at `/allow`:
+
+```
+pyrtan hub --allow alice@pyrtan.com=ask,run --allow pyr:…=operate
+pyrtan allow bob@example.org --may chat --at HUB # GET/POST /allow
+pyrtan allowed --at HUB
+pyrtan disallow bob@example.org --at HUB # POST /disallow
+```
+
+A name is resolved and pinned when the grant is made; from then on the
+grant is held against the identifier, and the name is never resolved
+again. `/allow` and `/disallow` need the token or a key granted
+`operate`, even on a hub with no token: a grant of `run` makes a trusted
+member, which an open hub never had, so there grants come only from
+`--allow` at start. What a request may do is the grant's abilities intersected with
+the device note's `may` (`grants.py`, `ability_for` says which a path
+needs):
+
+| 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; 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
+(`trusted_by: key`); one without is a keeper that says who it is. When
+its note expires, its root's document revokes it, or its root loses
+`run`, the hub drops the link; a member trusted by the token is not
+touched.
+
+**Hubs.** A hub may have a root key too: `/actor` gains `key` and a
+signature over `{id, key}`. A follower pins the key. A hub that moves
+(new box, new domain, now an onion service) and serves the same key at
+its new `/actor` is recognised as the same hub, which is the case
+`members.md` §3 handles today only by keeping the same name.
+
+## 8. Signatures: where the code comes from
+
+The standard library has no public-key signatures, and `federation.md`
+§7 already declined to write Ed25519 by hand for directory entries,
+because a follow gave the same property. Here nothing else gives it: a
+hub that checks Alice with a shared secret can also forge Alice.
+
+- **Python: `cryptography`,** as an optional extra, `pyrtan[identity]`,
+ the way Playwright is `pyrtan[browser]`. Imported lazily by
+ `identity.py`. Without it, encoding and checking identifiers still
+ works (§2); `pyrtan id new`, signing and verifying say how to install
+ the extra; a hub started with 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-library
+ only, as `CONTRIBUTING.md` requires; identity is opt-in at each hub,
+ and so is its dependency.
+- **The browser: WebCrypto's `Ed25519`,** shipped in all three engines,
+ available only in secure contexts. The console at
+ `https://pyrtan.com/console/`, 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
+ accepting a forgery through a missed check, which RFC 8032's and
+ Wycheproof's vectors are for. Signing would still never be pure Python.
+ Not taken now; written down so that the choice is a choice.
+
+## 9. Paths considered and not taken
+
+- **`user@server` as the identity** (email, Matrix, ActivityPub's
+ actors). The server owns the user. Kept as the *name* form (§6), where
+ pinning makes it safe.
+- **A hash of the key** (`hash_id`). Same length for Ed25519, and a
+ verifier then needs the key carried separately. The version byte gives
+ the agility a hash would have.
+- **`did:key`.** A standard, and a `pyr:` identifier converts to one and
+ back mechanically. But it is base58btc (another codec, mixed case, not
+ a DNS label) and arrives with the DID resolution machinery. A
+ conversion can be offered later; it is not the format.
+- **Nostr's `npub`.** Close in spirit; bech32 is a codec the standard
+ library does not have, and base32 plus SHA3 is.
+- **The onion encoding exactly.** Would make an identity and an onion
+ address the same string, and invite one key for both (§2).
+- **GitHub as the identity** (OAuth, or `github.com/<login>.keys` as the
+ registry). OAuth needs a server for the exchange, or makes every hub
+ ask GitHub who someone is; `.keys` makes GitHub the authority and has
+ no place for a phone's key. GitHub is used once, by the gate, to say
+ who opened a pull request, which is all pyrtan.com's names need.
+- **A global namespace** (a chain, a DHT of names). Names are per domain
+ on purpose: there is nothing global to squat on or to fight over.
+- **`<id>.pyrtan.com`.** GitHub Pages serves no wildcard subdomains, and
+ the path form does the same job.
+
+## 10. Sequencing
+
+- **First, built: the identifier and the documents.** `identity.py`:
+ encode, decode, checksum, parse the three forms, canonical document
+ signing and checking, reissuing a document strictly newer; `pyrtan id
+ new`, `show`, `check`, `device add`, `revoke`, `route add|remove`, and
+ `name` (signs a claim, ready for the second step); the root key in
+ `$PYRTAN_IDENTITY`, else `~/.config/pyrtan/identity/root.key`, mode
+ 600, beside `identity.json`. Tested with RFC 8032's vectors and the
+ vector in §2; CI installs the extra so signing is tested, and the
+ suite still passes without it.
+- **Second, built: names.** `names.py`: resolving, pins, building
+ `/.well-known/pyrtan/` from a directory of claims, and pyrtan.com's
+ gate; `pyrtan id resolve`, `pins`, `wellknown`, `name --into`;
+ `site/names/` with its README, `site/gate.py --author-id` and
+ `--root`, the `catalog-gate` workflow running main's gate on the pull
+ request's tree with the author's numeric id, and the `pages`
+ build serving the names. pyrtan.com hands out names; nothing yet
+ grants by them.
+- **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 to
+ showing a verified name (chat stays anonymous by default, and still
+ keeps nothing); reaching `.onion` locators, which needs a SOCKS proxy
+ the transport does not have and an egress rule for it (`io.md`);
+ signing directory entries, now that keys exist, if a reason appears.
+
+## 11. Failure modes, honestly
+
+- **A lost root key is a lost identity.** Device notes expire within a
+ year and nothing can renew them. The person makes a new root, re-pairs
+ their devices, and asks each name provider and each hub operator to
+ point at it. The CLI says this when it makes a root, and names the
+ file to back up.
+- **A stolen device** works until it is revoked and the hubs re-read the
+ document: up to an hour, plus however long the new document takes to
+ reach a route the hub asks. `may` bounds what it can do meanwhile.
+- **A stolen root** is the identity, stolen. The thief can revoke the
+ real devices and sign a new document. Nothing in this design takes the
+ identity back; the person starts again and tells people, out of band.
+- **A name provider remaps a name.** Pinned clients refuse to follow and
+ show both identities. A client that never resolved the name before
+ gets the new answer; that is what a name is.
+- **A GitHub login changes hands.** The gate holds the numeric id, so
+ the new holder of the login cannot edit the old claim. The old claim
+ stays until its owner or a maintainer removes it.
+- **pyrtan.com is down or gone.** Identifiers, documents held by hubs,
+ and every pin keep working. Only resolving a name for the first time
+ needs the domain.
+- **Clocks.** The same five-minute window as HMAC; a device whose clock
+ is off is refused with `signature too old`, as a hub is today.
+- **A console on plain `http://`** cannot hold a key (§8) and joins as an
+ anonymous member.
+
+## 12. What this design does not claim
+
+It does not claim anonymity: an identity is a stable pseudonym, linkable
+across every hub it signs to, which is the point. A person who wants two
+unlinkable selves makes two roots. It does not claim a name is a real
+person, or a key a trustworthy one; it claims only that the same key
+signed both times. It does not claim recovery. It does not claim that
+an identity can do anything on a hub whose operator did not grant it, or
+that granting it is safe; a grant is trust, as a follow is, and the trace
+and the logs will name whom it was given to.
diff --git a/_meta/members.md b/_meta/members.md index 4444669..69bbba3 100644 --- a/_meta/members.md +++ b/_meta/members.md
@@ -158,7 +158,11 @@ browser that has joined no hub is a hub of its own
decides 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.14 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/_meta/platform.md b/_meta/platform.md index 62deacb..ae3c331 100644 --- a/_meta/platform.md +++ b/_meta/platform.md
@@ -114,8 +114,10 @@ shape whatever the sources look like.
`pyrtan` namespace) and `catalog/<namespace>/<name>.toml`, each namespace with an `OWNERS` file of GitHub logins. `pyrtan publish FILE --into CHECKOUT --owner LOGIN` places the file, writes `OWNERS` for a new
-namespace, and runs the gate; the `catalog` workflow runs the same gate
-on every pull request with what git adds: the changed files, the
+namespace, and runs the gate; the `catalog-gate` workflow runs main's
+copy of the same gate (`pull_request_target`, the pull request's tree
+read as data and never run, so a pull request cannot change the gate
+that judges it) on every pull request with what git adds: the changed files, the
author, the version each tree had on `main`. The gate (`catalog.gate`, `site/gate.py`) refuses a tree that does not load clean, lacks a licence or a namespace, sits under the wrong directory or file name, shares an id diff --git a/catalog/README.md b/catalog/README.md index 82fdda5..ca01039 100644 --- a/catalog/README.md +++ b/catalog/README.md
@@ -12,7 +12,8 @@ naming the GitHub logins that may change the namespace:
`pyrtan publish my-tree.toml --into <this checkout> --owner <your login>` puts a tree in the right place and runs the gate; then open a pull
-request. The `catalog` workflow runs the same gate on the pull request:
+request. The `catalog-gate` workflow runs the same gate on the pull request,
+main's copy of it, so a pull request cannot change the gate that judges it:
every tree loads clean, carries `license` and `namespace`, sits in its namespace's directory under its own name, ids are unique, only owners change a namespace, and a changed file bumps its `version`. A maintainer diff --git a/pyproject.toml b/pyproject.toml index 7b1a867..ead4e43 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project] name = "pyrtan"
-version = "0.13.0"
+version = "0.14.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"
@@ -26,6 +26,7 @@ pyrtan = "pyrtan.cli:main"
[project.optional-dependencies] browser = ["playwright>=1.45"]
+identity = ["cryptography>=42"]
[dependency-groups] dev = ["pytest>=8"] diff --git a/site/README.md b/site/README.md index 76a30de..21fcf95 100644 --- a/site/README.md +++ b/site/README.md
@@ -7,13 +7,14 @@ 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 | `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 holds that key | `pyrtan console --out` |
| `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, `index.json` the command reads, and `trees.json`, the same trees as JSON for the console | `pyrtan index examples catalog --copy`, through `site/gate.py` | | `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` | | `/rendezvous/hubs.json` | the hub list: hubs operators added by pull request, which the snapshot asks first and `pyrtan join` tries first | `site/rendezvous/hubs.json` | | `/rendezvous/directory.json` | a snapshot of the default network's directory, from every hub the build can reach, refreshed hourly | `site/snapshot.py` | | `/rendezvous/reports.json` | the gap reports the hubs carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |
+| `/.well-known/pyrtan/` | names: `name/<login>.json` (a claim signed by the identity it names), `id/<56>.json` (identity documents), `names.json` (every claim), for `pyrtan id resolve <login>@pyrtan.com` | `pyrtan id wellknown site/names`, after `site/gate.py`; see [`names/README.md`](names/README.md) |
`site/build.sh out` builds the same thing locally; open `out/index.html`.
@@ -45,8 +46,9 @@ The catalog is `examples/` (the `pyrtan` namespace) and `catalog/`
(everyone else's, one directory per namespace with an `OWNERS` file), served as `/catalog/<namespace>/<name>.toml` beside `index.json`. A tree goes in by pull request: `pyrtan publish my-tree.toml --into <checkout> ---owner <login>` places it and runs the gate, the `catalog` workflow runs
-the same gate on the pull request (`site/gate.py`: clean, licensed,
+--owner <login>` places it and runs the gate, the `catalog-gate` workflow runs
+main's copy of the same gate on the pull request, reading the pull request's
+tree as data and running none of it (`site/gate.py`: clean, licensed,
namespaced, in the right place, unique id, changed only by an owner, version bumped), and a maintainer merges. The `pages` workflow runs the gate and the tests again before it publishes, so what is at `/catalog/` diff --git a/site/build.sh b/site/build.sh index d187c20..1399272 100755 --- a/site/build.sh +++ b/site/build.sh
@@ -1,6 +1,7 @@
#!/bin/sh # Assemble pyrtan.com into one static directory: the landing page, the
-# console, the catalog (built from examples/ and catalog/), the hub list, the source. Run from the
+# console, the catalog (built from examples/ and catalog/), the hub list, the source,
+# the names. Run from the
# repository root with pyrtan installed (`uv run site/build.sh`); the Pages # workflow runs the same thing. set -eu
@@ -35,5 +36,8 @@ pyrtan check examples catalog --quiet
python3 site/gate.py # The index and every tree file beside it, as <namespace>/<name>.toml. pyrtan index examples catalog --copy "$out/catalog" --require-namespace
+# Names: site/names/ served as /.well-known/pyrtan/ (name claims, identity
+# documents, and names.json listing them), for `pyrtan id resolve`.
+pyrtan id wellknown site/names --domain "$(cat site/CNAME)" --out "$out"
touch "$out/.nojekyll" echo "built $out: $(find "$out" -type f | wc -l) files" diff --git a/site/gate.py b/site/gate.py index 398a320..04b8c11 100644 --- a/site/gate.py +++ b/site/gate.py
@@ -1,14 +1,26 @@
"""The catalog gate, as the workflows run it.
- uv run python site/gate.py [--base origin/main] [--author LOGIN] [--changed FILE ...]
+ uv run python site/gate.py [--root CHECKOUT] [--base origin/main] [--author LOGIN] [--author-id ID] [--changed FILE ...]
-Runs :func:`pyrtan.catalog.gate` on this checkout: every tree under
+Runs :func:`pyrtan.catalog.gate` on a checkout (``--root``, default this
+one): every tree under
``examples/`` and ``catalog/`` loads clean with a licence and a namespace, sits where its namespace and name say, ids are unique, each namespace has owners. With ``--base`` it asks git which catalog files the pull request changed and what version each had before, so a change must come from an owner (``--author``, the pull request's login) and must bump the version.
+
+Then :func:`pyrtan.names.gate` on ``site/names/``: every name claim and
+identity document holds, a name is a GitHub login, one per account. With
+``--base``, a new name must be the author's own login carrying the
+author's numeric id (``--author-id``), and a name may be changed or
+removed only by the account whose id it carried on the base branch.
Exit 1 with the problems listed, 0 when the gate is open.
+
+The code that judges is always this file's own checkout (its ``src/``);
+``--root`` is only what is judged. The ``catalog-gate`` workflow runs
+main's copy of this file against a pull request's tree, so a pull request
+cannot change the gate that judges it.
""" from __future__ import annotations
@@ -23,33 +35,46 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src")) from pyrtan.catalog import gate # noqa: E402
+from pyrtan.names import NAMES_DIR, gate as names_gate # noqa: E402
-def git(*args: str) -> str | None:
+def git(root: Path, *args: str) -> str | None:
try:
- return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True).stdout
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=True).stdout
except (subprocess.CalledProcessError, FileNotFoundError): return None def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("--root", type=Path, default=ROOT, help="the checkout to judge (default: this file's own); the gate's code is always this file's checkout")
ap.add_argument("--base", help="the base ref of the pull request (e.g. origin/main); changes and prior versions come from git") ap.add_argument("--author", help="the pull request's GitHub login; must own any namespace it changes")
+ ap.add_argument("--author-id", type=int, help="the pull request author's numeric GitHub id; must own any name it changes")
ap.add_argument("--changed", nargs="*", default=None, help="changed paths, when not taken from git") args = ap.parse_args(argv)
+ root = args.root.resolve()
changed = args.changed base: dict[str, tuple[int, bytes]] = {}
+ names_base: dict[str, bytes] = {}
if args.base:
- out = git("diff", "--name-only", f"{args.base}...HEAD")
+ # --no-renames: a rename is a removal and an addition, and both are judged.
+ out = git(root, "diff", "--name-only", "--no-renames", f"{args.base}...HEAD")
if out is None: print(f"gate: cannot diff against {args.base}; checking the tree without change information", file=sys.stderr) else: changed = [line.strip() for line in out.splitlines() if line.strip()] if changed is None else changed for path in changed:
+ if path.startswith(f"{NAMES_DIR}/"):
+ # A name's claim on the base branch says whose it is, for its .id.json too.
+ claim = path[: -len(".id.json")] + ".json" if path.endswith(".id.json") else path
+ prior = subprocess.run(["git", "show", f"{args.base}:{claim}"], cwd=root, capture_output=True)
+ if prior.returncode == 0:
+ names_base[claim] = prior.stdout
+ continue
if not (path.startswith(("catalog/", "examples/")) and path.endswith(".toml")): continue
- prior = subprocess.run(["git", "show", f"{args.base}:{path}"], cwd=ROOT, capture_output=True)
+ prior = subprocess.run(["git", "show", f"{args.base}:{path}"], cwd=root, capture_output=True)
if prior.returncode != 0: continue # new on this branch try:
@@ -57,7 +82,8 @@ def main(argv: list[str] | None = None) -> int:
except Exception: version = 1 base[path] = (version, prior.stdout)
- problems = gate(ROOT, changed=changed, author=args.author, base=base)
+ problems = gate(root, changed=changed, author=args.author, base=base)
+ problems += names_gate(root, changed=changed, author=args.author, author_id=args.author_id, base=names_base)
for p in problems: print(f"gate: {p}") scope = f"{len(changed)} changed file(s)" if changed is not None else "the whole checkout" diff --git a/site/names/README.md b/site/names/README.md new file mode 100644 index 0000000..c509e8d --- /dev/null +++ b/site/names/README.md
@@ -0,0 +1,47 @@
+# Names at pyrtan.com
+
+A name here is `<your GitHub login>@pyrtan.com`, pointing at your
+pyrtan identity (a key: `pyr:` and 56 characters). Anyone resolves it with
+
+ pyrtan id resolve alice@pyrtan.com
+
+which fetches `https://pyrtan.com/.well-known/pyrtan/name/alice.json`,
+checks that your key signed it, and pins the answer: if this file ever
+names a different key, everyone who resolved it before is told rather
+than switched. pyrtan.com is one name provider among any number; your
+identity is the key, and works without a name.
+
+## Getting one
+
+ 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>
+
+That writes `site/names/<login>.json` (the claim, signed by your key,
+with your numeric GitHub user id, which it looks up from your login; pass
+`--github ID` to give it yourself) and `site/names/<login>.id.json` (your
+identity document: routes, devices, revocations). Commit both and open a
+pull request **from the GitHub account the name is**. When your identity
+document changes (a device added or revoked), copy the new one over
+with `pyrtan id show --json > site/names/<login>.id.json` and open
+another.
+
+## What the gate checks
+
+The `catalog-gate` workflow runs main's `site/gate.py` on the pull request
+(a pull request that edits the gate is still judged by main's):
+
+- every claim is for `<its file's name>@pyrtan.com` and signed by the key
+ it names; every identity document is signed by that same key;
+- a name is a GitHub login, and each GitHub account holds at most one;
+- a **new** name is the pull request author's own login, and the claim's
+ `github` is the author's numeric id;
+- a name is **changed or removed** only by the account whose numeric id it
+ carried before. GitHub frees a login when its owner renames, so the id,
+ not the login, is what owns a name. To follow a rename, remove the old
+ name and add the new one in the same pull request.
+
+A maintainer merges; the `pages` workflow publishes within the hour. A
+maintainer may merge over the gate to remove a name on its owner's
+request or for abuse. The design is in
+[`_meta/identity.md`](../../_meta/identity.md) §6.
diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index ec3238a..b69de05 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -33,4 +33,4 @@ __all__ = [
"Response", ]
-__version__ = "0.13.0"
+__version__ = "0.14.0"
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 8a79f27..957dbbb 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -26,6 +26,9 @@
pyrtan pull NAME[@VERSION] [--dir] fetch a tree from a catalog, verified pyrtan index DIR [--out FILE] build a catalog index from a directory pyrtan console --out FILE write the console as one static HTML file
+ 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
``serve`` and ``hub`` take ``--token`` (or ``$PYRTAN_TOKEN``) to require a bearer token, ``--cors ORIGIN`` to let a browser elsewhere call them, and
@@ -48,6 +51,7 @@ from . import __version__
from .catalog import Catalog, CatalogError, build_bundle, build_index, catalog_url from .federation import bootstrap_candidates from .grow import Proposal, propose
+from .identity import ABILITIES, KINDS, IdentityError
from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER from .registry import Registry, default_paths from .runtime import Outcome, Runner
@@ -83,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--hub", help="forward trees not held here to this hub or service") p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value trees may read as {{ env.K }} (repeatable)") p.add_argument("--egress", choices=EGRESS_POLICIES, default="any", help="'public' refuses private, loopback and link-local addresses")
- _token_arg(p, "the token the hub (--hub) requires, if any")
+ _token_arg(p, "the token the hub (--hub) requires, if any", sign=False)
p = sub.add_parser("match", help="how a sentence reads against the trees: the rewrites, every trigger's verdict, the winner") p.add_argument("text")
@@ -130,7 +134,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any")) p.add_argument("--hub", help="delegate to this hub what the trees here do not hold") p.add_argument("--json", action="store_true", help="one JSON outcome per line instead of prose")
- _token_arg(p, "the hub's token, if any")
+ _token_arg(p, "the hub's token, if any", sign=False)
p = sub.add_parser("watches", help="what a running service or hub is watching, and what each watch last did") p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="a running service or hub (default $PYRTAN_AT or the local hub)")
@@ -172,6 +176,8 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") 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("--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("--chat-public", action="store_true", default=os.environ.get("PYRTAN_CHAT_PUBLIC", "").lower() in ("1", "true", "yes"), help="open the lobby to callers without the token (implies --chat): they meet people only, never a bot, and everything else still needs the token (default $PYRTAN_CHAT_PUBLIC)") p.add_argument("--chat-typing", action=argparse.BooleanOptionalAction, default=os.environ.get("PYRTAN_CHAT_TYPING", "").lower() not in ("0", "false", "no", "off"), help="the lobby's typing indicator: \"Stranger is typing…\" for anyone who keeps theirs on (default on; $PYRTAN_CHAT_TYPING=0 turns it off)") p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)")
@@ -273,26 +279,115 @@ 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("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)")
+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")
+ _token_arg(p, "your hub's token, if any")
+
+ p = sub.add_parser("allowed", help="the identities your hub grants anything, and what")
+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")
+ p.add_argument("--json", action="store_true")
+ _token_arg(p, "your hub's token, if any")
+
+ p = sub.add_parser("disallow", help="take back everything your hub grants an identity")
+ p.add_argument("who", metavar="pyr:…|NAME@DOMAIN")
+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")
+ _token_arg(p, "your hub's token, if any")
+
p = sub.add_parser("console", help="write the console as one static HTML file, to host anywhere") p.add_argument("--out", required=True, help="the file to write ('-' for stdout)")
+ p = sub.add_parser("id", help="your identity: a key, reachable at any domain, named by any domain (_meta/identity.md)")
+ ids = p.add_subparsers(dest="id_command", required=True)
+ q = ids.add_parser("new", help="make your root key and identity document")
+ q.add_argument("--kind", choices=KINDS, default="person", help="what the identity says it is (default person)")
+ q.add_argument("--route", action="append", default=[], metavar="HOST", help="a hub or .onion that takes messages for you (repeatable)")
+ q = ids.add_parser("show", help="your identity, its routes and devices")
+ q.add_argument("--json", action="store_true", help="print the signed identity document")
+ q = ids.add_parser("check", help="check an identity, address or name, or a document (a file, or - for stdin)")
+ q.add_argument("thing", help="pyr:…, pyr:…@host, name@domain, or a JSON file")
+ q = ids.add_parser("device", help="delegate to a device key: a phone, a browser, another machine")
+ q.add_argument("action", choices=["add"])
+ q.add_argument("device", metavar="pyr:…", help="the device's identity")
+ q.add_argument("--may", default="ask,chat", help=f"what the device may ever be granted, comma-separated from {','.join(ABILITIES)} (default ask,chat)")
+ q.add_argument("--label", default="", help="what to call it: phone, laptop")
+ q.add_argument("--days", type=int, default=365, help="how long the note lasts, at most 365 (default 365)")
+ q = ids.add_parser("revoke", help="revoke a device; hubs drop it when they next read your document")
+ q.add_argument("device", metavar="pyr:…")
+ q = ids.add_parser("route", help="add or remove a place you can be reached")
+ q.add_argument("action", choices=["add", "remove"])
+ q.add_argument("host", help="a host[:port] or <56>.onion")
+ q = ids.add_parser("name", help="sign a claim that you are name@domain, for the domain to serve")
+ q.add_argument("name", metavar="NAME@DOMAIN")
+ 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")
+ 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("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")
+ q.add_argument("names", type=Path, metavar="DIR", help="<name>.json claims, and <name>.id.json identity documents")
+ q.add_argument("--domain", required=True, help="the domain that will serve them; every claim must be for it")
+ q.add_argument("--out", type=Path, required=True, help="the site's root; .well-known/pyrtan/ is written under it")
+ for q in ids.choices.values():
+ q.add_argument("--dir", type=Path, default=None, help="where your key and document live (default $PYRTAN_IDENTITY or ~/.config/pyrtan/identity)")
+
args = parser.parse_args(argv) commands = { "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "chat": cmd_chat, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new, "watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow, "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,
+ "keep": cmd_keep, "kept": cmd_kept, "fetch": cmd_fetch, "id": cmd_id,
+ "allow": cmd_allow, "allowed": cmd_allowed, "disallow": cmd_disallow,
} return commands[args.command](args)
-def _token_arg(p: argparse.ArgumentParser, help_text: str) -> None:
+def _token_arg(p: argparse.ArgumentParser, help_text: str, *, sign: bool = True) -> None:
p.add_argument("--token", default=os.environ.get("PYRTAN_TOKEN"), help=f"{help_text} (default $PYRTAN_TOKEN)")
+ if sign:
+ p.add_argument("--sign", action="store_true", default=os.environ.get("PYRTAN_SIGN", "").lower() in ("1", "true", "yes"),
+ help="sign requests with your identity's key (pyrtan id new), for a hub that grants it (default $PYRTAN_SIGN)")
+
+
+_AUDIENCES: dict[str, str] = {}
+
+
+def _call(args: argparse.Namespace, method: str, url: str, payload: Any = None, **kw: Any) -> tuple[int, Any]:
+ """``http_json`` with the command's token, and with its key's signature under --sign."""
+ headers = _key_headers(args, method, url, payload) if getattr(args, "sign", False) else None
+ return http_json(method, url, payload, token=getattr(args, "token", None), headers=headers, **kw)
+
+
+def _key_headers(args: argparse.Namespace, method: str, url: str, payload: Any) -> dict[str, str]:
+ """Sign as the root key in $PYRTAN_IDENTITY (or ~/.config/pyrtan/identity), for the
+ audience the hub names at /api."""
+ from urllib.parse import urlsplit
+ from . import identity
+ parts = urlsplit(url)
+ base = f"{parts.scheme}://{parts.netloc}"
+ if base not in _AUDIENCES:
+ status, api = http_json("GET", f"{base}/api", timeout=10.0)
+ audience = api.get("audience") if status == 200 and isinstance(api, dict) else None
+ if not isinstance(audience, str):
+ raise SystemExit(f"pyrtan: {base} names no audience to sign for; is it a hub of pyrtan 0.14 or later?")
+ _AUDIENCES[base] = audience
+ key = identity.Key.load(identity.home() / "root.key")
+ if method == "POST" and payload is None:
+ payload = {}
+ return identity.sign_request(key, _AUDIENCES[base], method, parts.path or "/", payload)
def _access_args(p: argparse.ArgumentParser) -> None:
- _token_arg(p, "require this bearer token on every request but /health and the console")
+ _token_arg(p, "require this bearer token on every request but /health and the console", sign=False)
p.add_argument("--cors", action="append", default=[], metavar="ORIGIN", help="let a browser at this origin call (repeatable; '*' for any; e.g. https://pyrtan.com)")
@@ -453,12 +548,12 @@ def _answer_questions(outcome: Outcome, resume) -> Outcome:
def cmd_ask(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:
- status, body = http_json("POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace}, token=args.token)
+ status, body = _call(args, "POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace})
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} answered {status}: {body}", file=sys.stderr)
@@ -466,7 +561,7 @@ def cmd_ask(args: argparse.Namespace) -> int:
outcome = outcome_from_dict(body) def remote_resume(snapshot: dict[str, Any], answer: Any) -> Outcome:
- st, bd = http_json("POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace}, token=args.token)
+ st, bd = _call(args, "POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace})
return outcome_from_dict(bd) if isinstance(bd, dict) else Outcome("error", "", reason="remote", say=f"{at} answered {st}") outcome = _answer_questions(outcome, remote_resume)
@@ -478,7 +573,7 @@ def cmd_chat(args: argparse.Namespace) -> int:
want = "bot" if args.bot else "people" if args.people else "anyone" def post(path: str, body: dict[str, Any], timeout: float = 15.0) -> tuple[int, Any]:
- return http_json("POST", f"{at}{path}", body, timeout=timeout, token=args.token)
+ return _call(args, "POST", f"{at}{path}", body, timeout=timeout)
try: status, body = post("/chat/start", {"want": want, "interests": args.interests, "typing_indicator": not args.no_typing})
@@ -486,7 +581,7 @@ def cmd_chat(args: argparse.Namespace) -> int:
print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status != 200 or not isinstance(body, dict) or "seat" not in body: print(f"pyrtan: {at}: {body.get('say') if isinstance(body, dict) else body or status}", file=sys.stderr)
@@ -652,12 +747,12 @@ def cmd_watch(args: argparse.Namespace) -> int:
def cmd_watches(args: argparse.Namespace) -> int: try:
- status, body = http_json("GET", f"{args.at.rstrip('/')}/watches", token=args.token)
+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/watches")
except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 watches = (body or {}).get("watches", []) if isinstance(body, dict) else [] if args.json:
@@ -688,7 +783,7 @@ def cmd_grow(args: argparse.Namespace) -> int:
gaps: list[dict[str, Any]] = [] if args.at: try:
- status, body = http_json("GET", f"{args.at.rstrip('/')}/gaps", token=args.token)
+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/gaps")
except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3
@@ -786,16 +881,22 @@ def _from_report(r: dict[str, Any]) -> dict[str, Any]:
def cmd_hub(args: argparse.Namespace) -> int: from .hub import serve_hub
+ from .identity import IdentityError
follow = list(args.follow) join = list(args.join)
- hub, server = serve_hub(
- args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors,
- federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret,
- 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, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public,
- )
+ try:
+ hub, server = serve_hub(
+ args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors,
+ federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret,
+ 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, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public,
+ allow=args.allow, grants=args.grants,
+ )
+ except IdentityError as e:
+ print(f"pyrtan hub: {e}", file=sys.stderr)
+ return 1
fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") + (", listed" if hub.federation.listed else "") if hub.federation else "" local = f", holding {len(hub.local.trees)} tree(s)" if hub.local else "" if hub.local is not None and hub.local.service.watcher is not None:
@@ -804,6 +905,8 @@ def cmd_hub(args: argparse.Namespace) -> int:
local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members" if hub.lobby is not None: local += ", chatting at /chat" + (" (open to callers without the token, people only)" if hub.chat_public and args.token else "")
+ if len(hub.grants):
+ local += f", granting {len(hub.grants)} identit{'y' if len(hub.grants) == 1 else 'ies'}"
print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) try: server.wait()
@@ -820,12 +923,12 @@ def cmd_gaps(args: argparse.Namespace) -> int:
print("pyrtan gaps: give --at URL of a running service or hub", file=sys.stderr) return 2 try:
- status, body = http_json("GET", f"{args.at.rstrip('/')}/gaps", token=args.token)
+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/gaps")
except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 gaps = (body or {}).get("gaps", []) if isinstance(body, dict) else [] if args.json:
@@ -907,12 +1010,12 @@ def cmd_network(args: argparse.Namespace) -> int:
return _hub_post(args, "/leave", {"network": args.name}, lambda b: f"{args.at} left {args.name}" if b.get("left") else f"{args.at} was not in {args.name}") at = args.at.rstrip("/") try:
- status, body = http_json("GET", f"{at}/networks", token=args.token)
+ status, body = _call(args, "GET", f"{at}/networks")
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} is not a federated hub ({status})", file=sys.stderr)
@@ -940,12 +1043,12 @@ def cmd_keep(args: argparse.Namespace) -> int:
def cmd_kept(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:
- status, body = http_json("GET", f"{at}/keep", token=args.token)
+ status, body = _call(args, "GET", f"{at}/keep")
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} is not a federated hub ({status})", file=sys.stderr)
@@ -964,12 +1067,12 @@ def cmd_kept(args: argparse.Namespace) -> int:
def cmd_fetch(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:
- status, body = http_json("POST", f"{at}/fetch", {"key": args.key}, token=args.token)
+ status, body = _call(args, "POST", f"{at}/fetch", {"key": args.key})
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} answered {status}", file=sys.stderr)
@@ -1046,12 +1149,13 @@ def cmd_unfollow(args: argparse.Namespace) -> int:
def _hub_post(args: argparse.Namespace, path: str, payload: dict[str, Any], said) -> int: at = args.at.rstrip("/") try:
- status, body = http_json("POST", f"{at}{path}", payload, token=args.token)
+ status, body = _call(args, "POST", f"{at}{path}", payload)
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ said_401 = body.get("say") if isinstance(body, dict) and getattr(args, "sign", False) else None
+ print(f"pyrtan: {at}: {said_401}" if said_401 else f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status == 404: print(f"pyrtan: {at} is not a federated hub; start it with --federate", file=sys.stderr)
@@ -1066,12 +1170,12 @@ def _hub_post(args: argparse.Namespace, path: str, payload: dict[str, Any], said
def cmd_peers(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:
- status, body = http_json("GET", f"{at}/peers", token=args.token)
+ status, body = _call(args, "GET", f"{at}/peers")
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} answered {status}", file=sys.stderr)
@@ -1192,7 +1296,7 @@ def cmd_publish(args: argparse.Namespace) -> int:
print(f" git add {rel.parent}") print(f" git commit -m \"catalog: {rel.parent.name}/{rel.stem}\"") print(" git push -u origin HEAD")
- print("then open a pull request; the catalog workflow runs the same gate and a maintainer merges.")
+ print("then open a pull request; the catalog-gate workflow runs the same gate (main's copy) and a maintainer merges.")
return 0
@@ -1215,12 +1319,12 @@ def cmd_unanswered(args: argparse.Namespace) -> int:
print("pyrtan unanswered: give --at URL of a running service or hub", file=sys.stderr) return 2 try:
- status, body = http_json("GET", f"{args.at.rstrip('/')}/unanswered", token=args.token)
+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/unanswered")
except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3 if status == 401:
- print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)
+ print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)
return 3 entries = (body or {}).get("unanswered", []) if isinstance(body, dict) else [] if args.json:
@@ -1268,5 +1372,279 @@ def cmd_new(args: argparse.Namespace) -> int:
return 0
+# ---------------------------------------------------------------- pyrtan id
+
+
+def cmd_id(args: argparse.Namespace) -> int:
+ from . import identity
+ directory = args.dir or identity.home()
+ try:
+ return {
+ "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,
+ }[args.id_command](args, directory)
+ except IdentityError as e:
+ print(f"pyrtan id: {e}", file=sys.stderr)
+ return 1
+
+
+def _id_load(directory: Path):
+ """Your root key and your current identity document."""
+ from . import identity
+ key_file, doc_file = directory / "root.key", directory / "identity.json"
+ if not key_file.exists():
+ raise IdentityError(f"no identity in {directory}: make one with `pyrtan id new`")
+ key = identity.Key.load(key_file)
+ try:
+ doc = json.loads(doc_file.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{doc_file}: {e}") from None
+ problem = identity.check_identity(doc, ident=key.id)
+ if problem:
+ raise IdentityError(f"{doc_file}: {problem}")
+ return key, doc
+
+
+def _id_save(directory: Path, doc: dict[str, Any]) -> None:
+ path = directory / "identity.json"
+ temp = path.with_suffix(".json.tmp")
+ temp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ temp.replace(path)
+
+
+def _id_new(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key_file = directory / "root.key"
+ if key_file.exists():
+ print(f"pyrtan id: {key_file} exists; an identity is not replaced (move it away first, knowing that it is you)", file=sys.stderr)
+ return 1
+ key = identity.Key.generate()
+ doc = identity.make_identity(key, kind=args.kind, routes=args.route)
+ key.save(key_file)
+ _id_save(directory, doc)
+ print(f"you are {key.id}")
+ print(f"the key is {key_file} (readable only by you). It is the identity: lose it and nobody can give it back,")
+ print("copy it and whoever has the copy is you. Back it up somewhere offline.")
+ print(f"the document hubs and name providers hold is {directory / 'identity.json'}")
+ return 0
+
+
+def _id_show(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ _, doc = _id_load(directory)
+ if args.json:
+ print(json.dumps(doc, indent=2, ensure_ascii=False))
+ return 0
+ print(f"{doc['id']} ({doc['kind']}, updated {time.strftime('%Y-%m-%d %H:%M', time.localtime(doc['updated']))})")
+ print("routes: " + (", ".join(doc["routes"]) if doc["routes"] else "none (add one with `pyrtan id route add HOST`)"))
+ print(f"devices: {len(doc['devices'])}" + ("" if doc["devices"] else " (add one with `pyrtan id device add pyr:…`)"))
+ for note in doc["devices"]:
+ status = identity.device_status(doc, note["device"])
+ until = time.strftime("%Y-%m-%d", time.localtime(note["expires"]))
+ print(f" {note['device']} {note['label'] or '-':10} may {','.join(note['may']) or 'nothing'} {status} until {until}")
+ for device in doc["revoked"]:
+ print(f" {device} revoked")
+ return 0
+
+
+def _id_check(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ thing = args.thing
+ if thing != "-" and not Path(thing).is_file():
+ parsed = identity.parse(thing)
+ if parsed.form == "name":
+ print(f"{parsed}: a well-formed name; `pyrtan id resolve {parsed}` asks {parsed.domain} who it is")
+ else:
+ print(f"{parsed}: a well-formed {parsed.form}, key {identity.decode(parsed.id).hex()}")
+ return 0
+ try:
+ doc = json.loads(sys.stdin.read() if thing == "-" else Path(thing).read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{thing}: {e}") from None
+ kind, problem = identity.check_any(doc)
+ if problem:
+ print(f"{thing}: {kind}: refused: {problem}")
+ return 1
+ if kind == "identity":
+ 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']))}")
+ else:
+ print(f"{thing}: {doc['id']} claims {doc['name']}@{doc['domain']}, signed by it")
+ return 0
+
+
+def _id_device(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ if not 0 < args.days <= 365:
+ raise IdentityError("--days is from 1 to 365")
+ key, doc = _id_load(directory)
+ may = [a for a in args.may.replace(" ", "").split(",") if a]
+ issued = int(time.time())
+ note = identity.make_device(key, args.device, may, label=args.label, issued=issued, expires=issued + args.days * 86400)
+ _id_save(directory, identity.with_device(doc, key, note))
+ print(json.dumps(note, ensure_ascii=False))
+ print(f"{note['device']} may {','.join(note['may']) or 'nothing'} for you until {time.strftime('%Y-%m-%d', time.localtime(note['expires']))}; give the device the note above", file=sys.stderr)
+ return 0
+
+
+def _id_revoke(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key, doc = _id_load(directory)
+ device = identity.normal(args.device)
+ _id_save(directory, identity.with_revoked(doc, key, device))
+ print(f"revoked {device}; hubs drop it when they next read your document")
+ return 0
+
+
+def _id_route(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key, doc = _id_load(directory)
+ host = identity.check_locator(args.host)
+ routes = [r for r in doc["routes"] if r != host] + ([host] if args.action == "add" else [])
+ _id_save(directory, identity.with_routes(doc, key, routes))
+ print("routes: " + (", ".join(routes) or "none"))
+ return 0
+
+
+def _id_name(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity, names
+ key, doc = _id_load(directory)
+ github = args.github
+ if args.into 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)
+ 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)
+ 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']}")
+ return 0
+ if args.out:
+ Path(args.out).write_text(text, encoding="utf-8")
+ print(f"wrote {args.out}: {claim['id']} claims {claim['name']}@{claim['domain']}; the domain serves it at /.well-known/pyrtan/name/{claim['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)
+ name, ident = f"{claim['name']}@{claim['domain']}", claim["id"]
+ pins = names.Pins(directory / "pins.json")
+ verdict, pinned = pins.compare(name, ident), pins.get(name)
+ if verdict == "changed" and not args.repin:
+ print(f"pyrtan id: {name} now says it is {ident}", file=sys.stderr)
+ print(f" but it was pinned to {pinned['id']} on {time.strftime('%Y-%m-%d', time.localtime(pinned['first']))}", file=sys.stderr)
+ print(" the domain changed its answer, or someone is answering for it. If you know why, pyrtan id resolve --repin", file=sys.stderr)
+ return 1
+ pins.pin(name, ident)
+ pins.save()
+ doc = names.fetch_identity(ident, claim["domain"])
+ if args.json:
+ print(json.dumps({"claim": claim, "identity": doc}, indent=2, ensure_ascii=False))
+ return 0
+ since = {"new": "pinned now", "same": f"as pinned since {time.strftime('%Y-%m-%d', time.localtime(pinned['first'])) if pinned else ''}", "changed": "re-pinned"}[verdict]
+ print(f"{name} is {ident} ({since})")
+ if doc:
+ print("routes: " + (", ".join(doc["routes"]) or "none") + f"; {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked")
+ return 0
+
+
+def _id_pins(args: argparse.Namespace, directory: Path) -> int:
+ from . import names
+ pins = names.Pins(directory / "pins.json")
+ if args.forget:
+ if not pins.forget(args.forget):
+ print(f"pyrtan id: {args.forget} is not pinned", file=sys.stderr)
+ return 1
+ pins.save()
+ print(f"forgot {args.forget}; the next resolve takes what its domain says")
+ return 0
+ if not pins.names:
+ print("no pins yet: pyrtan id resolve NAME@DOMAIN")
+ for name, pin in sorted(pins.names.items()):
+ print(f"{name:32} {pin['id']} since {time.strftime('%Y-%m-%d', time.localtime(pin['first']))}")
+ return 0
+
+
+def _id_wellknown(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity, names
+ domain = identity.check_host(args.domain.lower())
+ count, problems = names.build_wellknown(args.names, args.out, domain)
+ for p in problems:
+ print(f"wellknown: {p} (left out)", file=sys.stderr)
+ print(f"wrote {count} name(s) for {domain} under {args.out / names.WELL_KNOWN}")
+ return 1 if problems else 0
+
+
+def _id_push(args: argparse.Namespace, directory: Path) -> int:
+ _, doc = _id_load(directory)
+ at = args.at.rstrip("/")
+ try:
+ status, body = http_json("POST", f"{at}/identity", {"document": doc}, timeout=15.0)
+ except Exception as e:
+ print(f"pyrtan id: {at}: {e}", file=sys.stderr)
+ return 3
+ if status != 200:
+ print(f"pyrtan id: {at} refused the document: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr)
+ return 1
+ print(f"{at} holds your document of {time.strftime('%Y-%m-%d %H:%M', time.localtime(doc['updated']))}: {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked")
+ return 0
+
+
+# ---------------------------------------------------------------- grants
+
+
+def cmd_allow(args: argparse.Namespace) -> int:
+ from .grants import parse_may
+ from .identity import IdentityError
+ try:
+ may = parse_may(args.may)
+ except IdentityError as e:
+ print(f"pyrtan allow: {e}", file=sys.stderr)
+ return 2
+ return _hub_post(args, "/allow", {"who": args.who, "may": may}, lambda b: f"{args.at} grants {b['grant']['name'] or b['grant']['id']} ({b['grant']['id']}): {', '.join(b['grant']['may'])}")
+
+
+def cmd_disallow(args: argparse.Namespace) -> int:
+ return _hub_post(args, "/disallow", {"who": args.who}, lambda b: f"{args.at} grants {args.who} nothing now" if b["removed"] else f"{args.at} granted {args.who} nothing")
+
+
+def cmd_allowed(args: argparse.Namespace) -> int:
+ at = args.at.rstrip("/")
+ try:
+ status, body = _call(args, "GET", f"{at}/allow")
+ except Exception as e:
+ print(f"pyrtan: {at}: {e}", file=sys.stderr)
+ return 3
+ if status in (401, 403):
+ print(f"pyrtan: {at}: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr)
+ return 3
+ if status != 200 or not isinstance(body, dict):
+ print(f"pyrtan: {at} answered {status}; is it a hub of pyrtan 0.14 or later?", file=sys.stderr)
+ return 3
+ if args.json:
+ print(json.dumps(body, indent=2, ensure_ascii=False))
+ return 0
+ grants = body.get("grants", [])
+ print(f"{at} (audience {body.get('audience')}) grants {len(grants)} identit{'y' if len(grants) == 1 else 'ies'}")
+ for g in grants:
+ doc = g.get("document")
+ held = f"document of {time.strftime('%Y-%m-%d', time.localtime(doc['updated']))}, {doc['devices']} device(s), {doc['revoked']} revoked" if doc else "no document yet (pyrtan id push)"
+ print(f" {g['name'] or '-':28} {g['id']} may {','.join(g['may'])} {held}")
+ return 0
+
+
if __name__ == "__main__": sys.exit(main()) diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 90a1b10..44554c1 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py
@@ -115,6 +115,7 @@ CHAT_TYPES = ("Meet", "Say", "Typing", "Part") # a lobby's: two strangers on t
UNLOGGED = ("Typing",) # said every couple of seconds while someone types: not worth a log line ASK_TIMEOUT = 30.0 # seconds a hub waits for a member to answer an Ask MEMBER_PREFIX = "urn:pyrtan:member:" # an actor with no inbox: a browser or a phone, which polls a mailbox
+KEY_PREFIX = "pyr:" # a member may be its device key instead (_meta/identity.md §7)
MAILBOX_MAX = 200 # activities a hub holds for a member that has not polled REPORT_TTL = 30 * 86400 # a gap report unheard of for this long is dropped REPORT_MAX = 2000
@@ -313,8 +314,12 @@ class Remote:
last_error: str | None = None since: float = field(default_factory=time.time) kind: str = "hub" # hub | member
- trusted: bool = False # a member that joined with the operator's token: it may run the hub's trees
+ trusted: bool = False # a member that joined with the operator's token, or as an identity granted run: it may run the hub's trees
network: str | None = None # the id of the network the follow was made in; None for a follow outside any
+ trusted_by: str | None = None # "token" or "key": how a trusted member came to be
+ identity: str | None = None # the root a member followed as, if it signed the follow with a key
+ device: str | None = None # the key that signed it
+ note: dict[str, Any] | None = None # its device note, when the key is a device
@property def link(self) -> str:
@@ -330,14 +335,17 @@ class Remote:
return "down" def to_dict(self) -> dict[str, Any]:
- return {"url": self.url, "name": self.name, "kind": self.kind, "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error, "trusted": self.trusted, "network": self.network}
+ return {"url": self.url, "name": self.name, "kind": self.kind, "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error, "trusted": self.trusted, "network": self.network, "identity": self.identity}
def to_state(self) -> dict[str, Any]:
- return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since, "kind": self.kind, "trusted": self.trusted, "network": self.network}
+ return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since, "kind": self.kind, "trusted": self.trusted, "network": self.network,
+ "trusted_by": self.trusted_by, "identity": self.identity, "device": self.device, "note": self.note}
@classmethod def from_state(cls, d: dict[str, Any]) -> "Remote":
- return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())), kind=d.get("kind", "hub"), trusted=bool(d.get("trusted", False)), network=d.get("network") if isinstance(d.get("network"), str) else None)
+ text = lambda k: d.get(k) if isinstance(d.get(k), str) else None # noqa: E731
+ return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())), kind=d.get("kind", "hub"), trusted=bool(d.get("trusted", False)), network=text("network"),
+ trusted_by=text("trusted_by"), identity=text("identity"), device=text("device"), note=d.get("note") if isinstance(d.get("note"), dict) else None)
def _shape_report(e: dict[str, Any], reporter: str, now: float) -> dict[str, Any] | None:
@@ -604,7 +612,7 @@ class Federation:
with self._lock: known = {n.id for n in self.networks.values()} for e in entries:
- if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not e["id"].startswith(("http://", "https://", MEMBER_PREFIX)):
+ if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not e["id"].startswith(("http://", "https://", MEMBER_PREFIX, KEY_PREFIX)):
continue hub_id = e["id"].rstrip("/") if hub_id == self.id:
@@ -627,7 +635,7 @@ class Federation:
self.directory_entries[key] = { "id": hub_id, "name": str(e.get("name") or "")[:80],
- "kind": "member" if hub_id.startswith(MEMBER_PREFIX) else "hub",
+ "kind": "member" if hub_id.startswith((MEMBER_PREFIX, KEY_PREFIX)) else "hub",
"trees": [str(t)[:80] for t in e.get("trees", []) if isinstance(t, (str, int))][:200], "seen": seen, "via": via,
@@ -1127,9 +1135,11 @@ class Federation:
return None return actor if verify(secret, method, path, body, h) is None else None
- def handle_inbox(self, activity: Any, headers: dict[str, str] | None, trusted: bool = False) -> tuple[int, dict[str, Any]]:
- """``trusted`` says the request also carried the operator's token: a
- member that follows that way may run the hub's trees."""
+ def handle_inbox(self, activity: Any, headers: dict[str, str] | None, trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]:
+ """``trusted`` says the request also carried the operator's token, or
+ a key whose root this hub grants ``run`` (``trusted_by`` says which):
+ a member that follows that way may run the hub's trees. ``signer`` is
+ the :class:`pyrtan.identity.Signer` of a key-signed request, if any."""
h = _lower(headers) if not isinstance(activity, dict) or activity.get("type") not in ACTIVITY_TYPES or not isinstance(activity.get("actor"), str): return 400, {"status": "error", "reason": "bad-activity", "say": f"Send an activity of type {', '.join(ACTIVITY_TYPES)} with an actor."}
@@ -1140,7 +1150,7 @@ class Federation:
if activity.get("type") not in UNLOGGED: self.inbox_log.append({**_logged(activity), "received": time.time()}) if kind == "Follow":
- return self._on_follow(actor, activity, h, trusted)
+ return self._on_follow(actor, activity, h, trusted, trusted_by=trusted_by, signer=signer)
followed = self.following.get(actor) follower = self.followers.get(actor) if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES):
@@ -1246,16 +1256,21 @@ class Federation:
return 404, {"status": "error", "reason": "not-kept", "say": f"Nothing kept here for {actor} under {key!r}."} return 200, {"status": "ok", "blob": blob} if kind == "Undo":
- with self._lock:
- self.followers.pop(actor, None)
- self.mailboxes.pop(actor, None)
- self._handed.pop(actor, None)
- self.store.drop_kept(actor)
- self._save()
+ self.drop_follower(actor)
return 200, {"status": "ok"} return 400, {"status": "error", "reason": "bad-activity"}
- def _on_follow(self, actor: str, activity: dict[str, Any], h: dict[str, str], trusted: bool = False) -> tuple[int, dict[str, Any]]:
+ def drop_follower(self, actor: str) -> bool:
+ """Forget a follower, its mailbox, what we handed it and what we keep for it."""
+ with self._lock:
+ gone = self.followers.pop(actor, None) is not None
+ self.mailboxes.pop(actor, None)
+ self._handed.pop(actor, None)
+ self.store.drop_kept(actor)
+ self._save()
+ return gone
+
+ def _on_follow(self, actor: str, activity: dict[str, Any], h: dict[str, str], trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]:
pair = activity.get("secret") inbox = activity.get("inbox") if not isinstance(pair, str) or len(pair) < 16 or not isinstance(inbox, str):
@@ -1282,8 +1297,11 @@ class Federation:
if inbox == "mailbox": # A member: a browser or a phone, with no address of its own. It # polls its mailbox here, signed, instead of being posted to.
- if not actor.startswith(MEMBER_PREFIX) or len(actor) < len(MEMBER_PREFIX) + 16:
- return 400, {"status": "error", "reason": "bad-activity", "say": f"A member's id is {MEMBER_PREFIX}<at least 16 characters>."}
+ if actor.startswith(KEY_PREFIX):
+ if signer is None or signer.key != actor:
+ return 401, {"status": "error", "reason": "unsigned", "say": f"A member whose id is a key signs its follow with that key (X-Pyrtan-Key: {actor})."}
+ elif not actor.startswith(MEMBER_PREFIX) or len(actor) < len(MEMBER_PREFIX) + 16:
+ return 400, {"status": "error", "reason": "bad-activity", "say": f"A member's id is {MEMBER_PREFIX}<at least 16 characters>, or its key, pyr:…."}
name = str(activity.get("name") or "")[:80] kind = "member" else:
@@ -1298,6 +1316,10 @@ class Federation:
kind = "hub" with self._lock: remote = Remote(actor, inbox, pair, name, state="accepted", last_ok=time.time(), kind=kind, trusted=bool(trusted and kind == "member"), network=net.id if net else None)
+ if remote.trusted:
+ remote.trusted_by = trusted_by or "token"
+ if signer is not None and kind == "member":
+ remote.identity, remote.device, remote.note = signer.root, signer.key, signer.note
self.followers[actor] = remote self._handed.pop(actor, None) self._save()
@@ -1305,7 +1327,7 @@ class Federation:
self.deliver(remote, self._activity("Announce", self._announcement(remote))) if remote.trusted: self.hand_out()
- return 202, {"status": "ok", "say": f"{self.id} accepts {actor}.", "mailbox": f"{self.id}/mailbox" if kind == "member" else None, "trusted": remote.trusted}
+ return 202, {"status": "ok", "say": f"{self.id} accepts {actor}.", "mailbox": f"{self.id}/mailbox" if kind == "member" else None, "trusted": remote.trusted, "identity": remote.identity}
# ------------------------------------------------------------ views diff --git a/src/pyrtan/grants.py b/src/pyrtan/grants.py new file mode 100644 index 0000000..0f6ee3b --- /dev/null +++ b/src/pyrtan/grants.py
@@ -0,0 +1,251 @@
+"""Grants: which identities may do what on this hub.
+
+An identity grants nothing by itself (``_meta/identity.md`` §1): a hub
+keeps its own list, from ``--allow`` and from its operator at ``/allow``,
+of roots and what each may do here: ``ask`` (the routed paths behind a
+token), ``chat`` (a seat in a lobby behind a token), ``run`` (follow as a
+member and be handed trees) and ``operate`` (everything the token allows).
+A device may do what its root's grant says, intersected with its note's
+``may``.
+
+A grant made by name (``alice@pyrtan.com``) resolves the name once and is
+held against the identifier from then on: the name is the pin, and is
+never resolved again, so a domain that later points it elsewhere moves
+nothing here. Each grant keeps
+the newest identity document it has seen, from a push to ``/identity``,
+from the name's domain or from the document's own routes, re-read at
+least hourly and on a request from a device not seen in a day; a device
+the document revokes is refused. The list lives in a JSON file beside
+the hub's state, or in memory without one.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Callable
+
+from . import identity, names
+from .identity import ABILITIES, IdentityError, Signer
+
+REFRESH_EVERY = 3600.0 # seconds between reads of a grant's identity document
+DEVICE_UNSEEN = 86400.0 # a device not seen for this long makes its root's document be re-read
+
+# Reached with a key whatever its grants: following as a member is open, and trust is decided there.
+OPEN = frozenset({"/inbox"})
+
+
+def ability_for(method: str, path: str) -> str | None:
+ """The ability a key-signed request needs for ``path`` on a hub with a token; None for
+ the paths anyone may reach (the public ones, the inbox, a pushed identity document)."""
+ from .service import public_path
+ if public_path(path) or path in OPEN:
+ return None
+ if path in ("/intent", "/run", "/resume") or (method == "GET" and path == "/capabilities"):
+ return "ask"
+ if path == "/chat" or path.startswith("/chat/"):
+ return "chat"
+ return "operate"
+
+
+def parse_may(text: Any) -> list[str]:
+ """``"ask,run"`` (or a list) as abilities, each once, in the canonical order."""
+ items = text if isinstance(text, list) else str(text or "").replace(" ", "").split(",")
+ items = [a for a in items if a]
+ unknown = [a for a in items if a not in ABILITIES]
+ if unknown or not items:
+ raise IdentityError(f"abilities are some of {', '.join(ABILITIES)}" + (f"; not {', '.join(unknown)}" if unknown else ""))
+ return [a for a in ABILITIES if a in items]
+
+
+def parse_spec(spec: str) -> tuple[str, list[str]]:
+ """``WHO=ask,run`` as ``(who, abilities)``; WHO is ``pyr:…`` or ``name@domain``."""
+ who, eq, may = spec.rpartition("=")
+ if not eq or not who:
+ raise IdentityError(f"{spec!r}: write WHO=ABILITIES, e.g. alice@pyrtan.com=ask,chat")
+ identity.parse(who)
+ return who, parse_may(may)
+
+
+@dataclass
+class Grant:
+ id: str
+ may: list[str]
+ name: str | None = None
+ doc: dict[str, Any] | None = None
+ fetched: float = 0.0
+ granted: float = field(default_factory=time.time)
+
+ def view(self) -> dict[str, Any]:
+ return {"id": self.id, "name": self.name, "may": self.may, "granted": self.granted,
+ "document": {"updated": self.doc["updated"], "routes": self.doc["routes"], "devices": len(self.doc["devices"]), "revoked": len(self.doc["revoked"])} if self.doc else None}
+
+ def to_state(self) -> dict[str, Any]:
+ return {"id": self.id, "may": self.may, "name": self.name, "doc": self.doc, "fetched": self.fetched, "granted": self.granted}
+
+
+class Grants:
+ def __init__(self, path: str | Path | None = None, *, fetch: names.Fetch | None = None,
+ on_change: Callable[[str], None] | None = None) -> None:
+ self.path = Path(path) if path else None
+ self.fetch = fetch
+ self.on_change = on_change # called with a root whose grant or document changed
+ self.grants: dict[str, Grant] = {}
+ self.seen: dict[str, float] = {} # device -> when it last signed here
+ self._lock = threading.RLock()
+ if self.path and self.path.exists():
+ self._load()
+
+ # ------------------------------------------------------------ the list
+
+ def __len__(self) -> int:
+ return len(self.grants)
+
+ def by_name(self, name: str) -> Grant | None:
+ key = str(identity.parse(name))
+ return next((g for g in self.grants.values() if g.name == key), None)
+
+ def allow(self, who: str, may: list[str]) -> Grant:
+ """Grant ``who`` (``pyr:…`` or a name, resolved and pinned now) the abilities ``may``."""
+ may = parse_may(may)
+ parsed = identity.parse(who)
+ if parsed.form == "name":
+ held = self.by_name(who)
+ claim = names.resolve(who, fetch=self.fetch) if held is None else None
+ ident = held.id if held else claim["id"]
+ name = str(parsed)
+ else:
+ ident, name, claim = parsed.id, None, None
+ with self._lock:
+ grant = self.grants.get(ident)
+ if grant is None:
+ grant = self.grants[ident] = Grant(ident, may, name)
+ else:
+ grant.may = may
+ grant.name = grant.name or name
+ self._save()
+ if claim is not None and grant.doc is None:
+ self.refresh_one(ident)
+ self._changed(ident)
+ return grant
+
+ def disallow(self, who: str) -> bool:
+ parsed = identity.parse(who)
+ grant = self.by_name(who) if parsed.form == "name" else self.grants.get(parsed.id)
+ if grant is None:
+ return False
+ with self._lock:
+ self.grants.pop(grant.id, None)
+ self._save()
+ self._changed(grant.id)
+ return True
+
+ def view(self) -> list[dict[str, Any]]:
+ with self._lock:
+ return [g.view() for g in sorted(self.grants.values(), key=lambda g: (g.name or "~", g.id))]
+
+ # ------------------------------------------------------------ callers
+
+ def may(self, signer: Signer, *, now: float | None = None) -> set[str]:
+ """What ``signer`` may do here: its root's grant, capped by the device note, and
+ nothing for a device its root's document revokes. Notes a device's visit."""
+ now = time.time() if now is None else now
+ grant = self.grants.get(signer.root)
+ if grant is None:
+ return set()
+ if signer.note is None:
+ return set(grant.may)
+ if grant.doc is not None and identity.device_status(grant.doc, signer.key, now=now) == "revoked":
+ return set()
+ last = self.seen.get(signer.key)
+ self.seen[signer.key] = now
+ if last is None or now - last > DEVICE_UNSEEN:
+ threading.Thread(target=self.refresh_one, args=(signer.root,), daemon=True).start()
+ return set(grant.may) & set(signer.note["may"])
+
+ def still(self, root: str, device: str, note: dict[str, Any] | None, ability: str, *, now: float | None = None) -> bool:
+ """Whether a member that followed as ``device`` for ``root`` may still do ``ability``."""
+ if note is not None and identity.check_device(note, root=root, now=now) is not None:
+ return False
+ return ability in self.may(Signer(device, root, note), now=now)
+
+ # ------------------------------------------------------------ documents
+
+ def document(self, ident: str) -> dict[str, Any] | None:
+ grant = self.grants.get(ident.lower())
+ return grant.doc if grant else None
+
+ def take_document(self, doc: Any, *, now: float | None = None) -> str | None:
+ """Keep ``doc`` if it is a granted root's and newer than ours; None, or why not."""
+ if not isinstance(doc, dict) or not isinstance(doc.get("id"), str):
+ return "not an identity document"
+ grant = self.grants.get(doc["id"].lower())
+ if grant is None:
+ return "this hub grants that identity nothing, so keeps no document for it"
+ problem = identity.check_identity(doc, ident=grant.id, now=now)
+ if problem:
+ return problem
+ with self._lock:
+ if grant.doc is not None and doc["updated"] <= grant.doc["updated"]:
+ return None
+ grant.doc = doc
+ self._save()
+ self._changed(grant.id)
+ return None
+
+ def refresh_one(self, ident: str) -> None:
+ """Look for a newer document where the grant's name and routes say it lives."""
+ grant = self.grants.get(ident)
+ if grant is None:
+ return
+ hosts = list(grant.doc["routes"]) if grant.doc else []
+ if grant.name:
+ hosts.insert(0, identity.parse(grant.name).domain)
+ grant.fetched = time.time()
+ for host in dict.fromkeys(hosts):
+ try:
+ doc = names.fetch_identity(ident, host, fetch=self.fetch)
+ except IdentityError:
+ continue # an onion route, for now
+ if doc is not None:
+ self.take_document(doc)
+
+ def refresh(self, *, now: float | None = None) -> None:
+ now = time.time() if now is None else now
+ for ident in [g.id for g in list(self.grants.values()) if now - g.fetched >= REFRESH_EVERY]:
+ self.refresh_one(ident)
+
+ # ------------------------------------------------------------ state
+
+ def _changed(self, ident: str) -> None:
+ if self.on_change:
+ self.on_change(ident)
+
+ def _save(self) -> None:
+ if not self.path:
+ return
+ with self._lock:
+ data = {"pyrtan-grants": 1, "grants": [g.to_state() for g in self.grants.values()]}
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ temp = self.path.with_suffix(self.path.suffix + ".tmp")
+ temp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ temp.replace(self.path)
+
+ def _load(self) -> None:
+ assert self.path
+ try:
+ data = json.loads(self.path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{self.path}: {e}") from None
+ if not isinstance(data, dict) or data.get("pyrtan-grants") != 1:
+ raise IdentityError(f"{self.path} is not a pyrtan grants file")
+ for d in data.get("grants", []):
+ try:
+ ident = identity.normal(d["id"])
+ doc = d.get("doc") if identity.check_identity(d.get("doc"), ident=ident) is None else None
+ self.grants[ident] = Grant(ident, parse_may(d.get("may")), d.get("name"), doc, float(d.get("fetched", 0)), float(d.get("granted", 0)))
+ except (IdentityError, KeyError, TypeError, ValueError):
+ continue
diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 3cf3f75..a71034f 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py
@@ -36,6 +36,19 @@ answer the same requests, so a client need not know which it is talking to,
and a swarm can start as one process and split later without the callers changing. A hub given a token requires it of callers (``/register`` included, so a stranger cannot join the swarm) and presents it to peers.
+
+**Identities.** A request signed with a key (``X-Pyrtan-Key`` and its
+signature, ``_meta/identity.md`` §7) is checked against the hub's
+*audience*, its own id, which ``GET /api`` names. On a hub with a token,
+it may do what the hub's grants say its root may (``ask``, ``chat``,
+``run``, ``operate``; see :mod:`pyrtan.grants`) and nothing else; on a hub
+without one it only says who is asking. A member that follows with a key
+whose root holds ``run`` is trusted as one that joined with the token,
+and is dropped when its grant, its note or its root's document says it
+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.
""" from __future__ import annotations
@@ -51,6 +64,9 @@ from typing import Any
from . import __version__ from .chat import Lobby, is_chat_path from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_hub, parse_invite, sign
+from . import identity
+from .grants import Grants, ability_for, parse_may
+from .identity import IdentityError
from .grow import Unanswered, no_match_outcome from .intent import Trigger, read from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER
@@ -127,8 +143,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) -> 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) -> None:
self.name = name
+ self.grants = grants if grants is not None else Grants()
+ self.grants.on_change = self._grant_changed
+ self._audience = audience
self.lobby = Lobby(self, typing=chat_typing) if chat or chat_public else None self.chat_public = chat_public # callers without the token may chat, with people only self.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them
@@ -163,6 +182,44 @@ class Hub:
self.federation.chat = self.lobby return self.federation
+ # ------------------------------------------------------------ identities
+
+ @property
+ def audience(self) -> str | None:
+ """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
+
+ 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):
+ return None, set(), None
+ if self.audience is None:
+ return None, set(), (401, {"status": "error", "reason": "unsigned", "say": "This hub has no id to check a key signature against; start it with --advertise."})
+ try:
+ signer = identity.verify_request(self.audience, method, path, body if method == "POST" else None, headers)
+ except IdentityError as e:
+ return None, set(), (401, {"status": "error", "reason": "unsigned", "say": f"The key signature does not hold: {e}."})
+ may = self.grants.may(signer)
+ needed = ability_for(method, path)
+ if needed == "chat" and self.chat_public:
+ needed = None # a public lobby takes a key it grants nothing as a guest, as it takes a caller with no key
+ if self.token and needed is not None and needed not in may:
+ granted = f"it may {', '.join(sorted(may))}" if may else "this hub grants it nothing"
+ return signer, may, (403, {"status": "error", "reason": "not-granted", "say": f"{identity.short(signer.root)} may not {needed} here: {granted}."})
+ return signer, may, None
+
+ def _grant_changed(self, root: str) -> None:
+ """Drop the members that followed as ``root`` and may no longer be what they are."""
+ fed = self.federation
+ if fed is None:
+ return
+ for remote in [r for r in list(fed.followers.values()) if r.kind == "member" and r.identity == root]:
+ note_ok = remote.note is None or identity.check_device(remote.note, root=root) is None
+ revoked = remote.note is not None and (doc := self.grants.document(root)) is not None and identity.device_status(doc, remote.device or "") == "revoked"
+ lost_run = remote.trusted_by == "key" and not self.grants.still(root, remote.device or "", remote.note, "run")
+ if not note_ok or revoked or lost_run:
+ fed.drop_follower(remote.url)
+
def handout_trees(self) -> dict[str, Tree]: """The trees a trusted member may run: the ones this hub holds itself, and the ones given it to hand out (``--handout``), by name. A member
@@ -239,6 +296,7 @@ class Hub:
def refresh_all(self) -> None: for peer in list(self.peers.values()): self.refresh(peer)
+ self.grants.refresh()
if self.federation is not None: self.federation.refresh_all() self.federation.announce() # only when what we hold changed
@@ -480,27 +538,32 @@ class Hub:
out.extend({**u, "peer": peer.url} for u in body.get("unanswered", [])) return {"unanswered": out}
- def _trusted(self, headers: dict[str, str]) -> bool:
- """Whether the caller holds this hub's token (always, when it has none)."""
- return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)
+ def _trusted(self, headers: dict[str, str], may: set[str] | frozenset[str] = frozenset()) -> bool:
+ """Whether the caller holds this hub's token (always, when it has none), or a key it grants chat: who meets bots in the lobby."""
+ return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token) or "chat" in may
def route(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str] | None = None) -> tuple[int, Any]: body = body or {} headers = headers or {} fed = self.federation
+ signer, may, refused = self._signer(method, path, body, headers)
+ if refused:
+ return refused
+ # The operator: the token, or a key whose root this hub grants operate.
+ operator = bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token or "") or "operate" in may
signed = any(k.lower() == "x-pyrtan-signature" for k in headers)
- if signed and not bearer_ok(headers.get("Authorization"), self.token or ""):
+ if signed and not operator:
# A hub we federate with is asking. It may ask the routed paths # and the inbox; anything else needs the operator's token. if fed is None or not signed_path(path): return 401, {"status": "error", "reason": "unauthorised", "say": "A signed request may only reach the inbox, the mailbox, the kept blobs and the routed paths."}
- signer = fed.verify_request(method, path, body if method == "POST" else None, headers) if path != "/inbox" else None
- if path != "/inbox" and signer is None:
+ actor = fed.verify_request(method, path, body if method == "POST" else None, headers) if path != "/inbox" else None
+ if path != "/inbox" and actor is None:
return 401, {"status": "error", "reason": "unsigned", "say": "The signature does not hold, or this hub does not know the actor."} if method == "GET" and path == "/mailbox":
- return 200, {"activities": fed.mailbox(signer or "")}
+ return 200, {"activities": fed.mailbox(actor or "")}
if method == "GET" and path.startswith("/kept/"):
- blob = fed.kept_for(signer or "", path[len("/kept/"):])
+ blob = fed.kept_for(actor or "", path[len("/kept/"):])
return (200, blob) if blob else (404, {"status": "error", "reason": "not-kept", "say": f"Nothing kept here for you under {path[len('/kept/'):]!r}."}) if method == "GET" and path == "/health": return 200, self.health()
@@ -515,11 +578,13 @@ class Hub:
return 200, fed.actor() if method == "GET" and path == "/directory": # A private network's part only to the operator (or to anyone, on a hub with no token).
- return 200, fed.directory(private=not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token))
+ return 200, fed.directory(private=not self.token or operator)
if method == "GET" and path == "/reports": return 200, fed.reports_view() if method == "POST" and path == "/inbox":
- return fed.handle_inbox(body, headers, trusted=bool(self.token) and bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token))
+ by_token = bool(self.token) and bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)
+ by_key = signer is not None and "run" in may
+ return fed.handle_inbox(body, headers, trusted=by_token or by_key, trusted_by="token" if by_token else "key" if by_key else None, signer=signer)
if method == "GET" and path in ("/following", "/followers"): return 200, {path[1:]: fed.to_dict()[path[1:]]} if method == "GET" and path == "/outbox":
@@ -581,6 +646,31 @@ class Hub:
if not isinstance(url, str): return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"https://hub.example\"}."} return 200, {"status": "ok", "removed": fed.unfollow(url)}
+ 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 "}.")}
+ try:
+ if path == "/disallow":
+ return 200, {"status": "ok", "removed": self.grants.disallow(who)}
+ grant = self.grants.allow(who, parse_may(body.get("may")))
+ except IdentityError as e:
+ return 400, {"status": "error", "reason": "bad-grant", "say": str(e)}
+ return 200, {"status": "ok", "grant": grant.view()}
+ if method == "POST" and path == "/identity":
+ problem = self.grants.take_document(body.get("document"))
+ if problem:
+ return 400, {"status": "error", "reason": "bad-identity", "say": problem}
+ return 200, {"status": "ok", "id": body["document"]["id"]}
+ 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
+ return (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 == "/capabilities": return 200, self.capabilities() if method == "GET" and path == "/gaps":
@@ -592,7 +682,7 @@ class Hub:
if is_chat_path(path): if self.lobby is None: return 404, {"status": "error", "reason": "no-chat", "say": "This hub keeps no lobby; start it with --chat."}
- return self.lobby.route(method, path, body, guest=not self._trusted(headers))
+ return self.lobby.route(method, path, body, guest=not self._trusted(headers, may))
if method == "GET" and path in ("/", "/index.html", "/console"): return 200, PAGE if method == "GET" and path == "/manifest.webmanifest":
@@ -602,12 +692,12 @@ 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", "/watches", "/register"]
+ endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.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: endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"]
- return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "chat_bots": self.lobby is not None and self._trusted(headers), "endpoints": endpoints}
+ return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "audience": self.audience, "grants": len(self.grants), "chat_bots": self.lobby is not None and self._trusted(headers, may), "endpoints": endpoints}
if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):
@@ -697,12 +787,26 @@ def serve_hub(
unanswered: str | Path | None = None, watch: bool = False, chat: bool = False,
+ allow: list[str] | None = None,
+ grants: str | None = None,
chat_typing: bool = True, chat_public: bool = False, ) -> tuple[Hub, Server]:
+ """Start a hub. ``allow`` holds ``WHO=ABILITIES`` grants, made before it answers
+ anything (a name is resolved now; IdentityError if it cannot be); ``grants`` is
+ 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 request
+ keep = grants or (str(Path(state).with_name("grants.json")) if state else None)
+ granted = Grants(keep)
+ 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)
- 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).start()
+ 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)
+ 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: hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps) for url in peers or []: diff --git a/src/pyrtan/identity.py b/src/pyrtan/identity.py new file mode 100644 index 0000000..54f2981 --- /dev/null +++ b/src/pyrtan/identity.py
@@ -0,0 +1,605 @@
+"""Identity: a person, a hub or a device as an Ed25519 key.
+
+An identity is its public key, written ``pyr:`` and 56 characters: the
+lowercase base32 of the 32-byte key, a 2-byte SHA3-256 checksum and a
+version byte, the layout of a Tor v3 onion address with Pyrtan's own
+checksum string. ``pyr:<key>@host`` says where an identity can be
+reached; ``alice@example.org`` is a name a domain vouches for. Encoding,
+decoding and parsing those need only the standard library.
+
+Three kinds of signed document stand on their own, so that anyone can
+hold a copy and nobody has to trust the holder: the *identity document*
+(routes, devices, revocations), the *device note* (a root delegating to
+a device key, with what it may do and until when), and the *name claim*
+(an identity saying it is ``name@domain``). Each is signed over
+``pyrtan-doc-v1\\n`` and the document's canonical JSON without ``sig``.
+
+Signing and verifying need ``cryptography`` (``pip install
+'pyrtan[identity]'``), imported only when a signature is made or
+checked. This module refuses to do Ed25519 itself: a private key is
+handled only by a maintained library. It also refuses floats in a
+document, so that every runtime produces the same bytes. The design is
+``_meta/identity.md``.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import ipaddress
+import json
+import os
+import re
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from .store import canonical
+
+PREFIX = "pyr:"
+VERSION_ED25519 = 1
+VERSIONS = {VERSION_ED25519: 32}
+CHECKSUM_TAG = b".pyrtan id"
+DOC_TAG = b"pyrtan-doc-v1\n"
+REQUEST_TAG = "pyrtan-req-v1"
+ID_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
+ABILITIES = ("ask", "chat", "run", "operate")
+KINDS = ("person", "hub")
+MAX_ROUTES = 16
+MAX_DEVICES = 64
+
+LOCAL = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
+LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
+INSTALL = "signatures need the identity extra: pip install 'pyrtan[identity]'"
+
+
+class IdentityError(ValueError):
+ pass
+
+
+# ---------------------------------------------------------------- identifiers
+
+
+def _checksum(key: bytes, version: int) -> bytes:
+ return hashlib.sha3_256(CHECKSUM_TAG + key + bytes([version])).digest()[:2]
+
+
+def encode(key: bytes, version: int = VERSION_ED25519) -> str:
+ """The identifier of a public key."""
+ if VERSIONS.get(version) != len(key):
+ raise IdentityError(f"a version {version} key is {VERSIONS.get(version, '?')} bytes, not {len(key)}")
+ raw = key + _checksum(key, version) + bytes([version])
+ return PREFIX + base64.b32encode(raw).decode("ascii").lower().rstrip("=")
+
+
+def decode(text: Any) -> bytes:
+ """The public key an identifier names; IdentityError says what is wrong."""
+ if not isinstance(text, str) or not text.lower().startswith(PREFIX):
+ raise IdentityError(f"{text!r} is not an identity: it starts {PREFIX}")
+ body = text[len(PREFIX):].lower()
+ if len(body) != ID_LENGTH:
+ raise IdentityError(f"{text!r} is not an identity: {len(body)} characters after {PREFIX}, not {ID_LENGTH}")
+ try:
+ raw = base64.b32decode(body.upper() + "=" * (-len(body) % 8))
+ except ValueError:
+ raise IdentityError(f"{text!r} is not an identity: not base32") from None
+ key, checksum, version = raw[:-3], raw[-3:-1], raw[-1]
+ if version not in VERSIONS:
+ raise IdentityError(f"{text!r} is version {version}, which this pyrtan does not know")
+ if _checksum(key, version) != checksum:
+ raise IdentityError(f"{text!r} is not an identity: the checksum does not match (a typo?)")
+ return key
+
+
+def normal(text: Any) -> str:
+ """The identifier as it is compared: checked, lowercase."""
+ decode(text)
+ return text.lower()
+
+
+def short(text: str) -> str:
+ """For display only; never compared."""
+ return f"{PREFIX}{text[len(PREFIX):len(PREFIX) + 4]}…{text[-4:]}"
+
+
+def check_locator(text: Any) -> str:
+ """``host``, ``host:port``, ``<56>.onion`` or ``[ip]:port``, lowercased."""
+ if not isinstance(text, str) or not text:
+ raise IdentityError(f"{text!r} is not a locator")
+ loc = text.lower()
+ host, port = loc, None
+ if loc.startswith("["):
+ end = loc.find("]")
+ if end < 0:
+ raise IdentityError(f"{text!r} is not a locator: an unclosed [")
+ host, rest = loc[1:end], loc[end + 1:]
+ try:
+ ipaddress.IPv6Address(host)
+ except ValueError:
+ raise IdentityError(f"{text!r} is not a locator: {host!r} is not an IPv6 address") from None
+ if rest:
+ if not rest.startswith(":"):
+ raise IdentityError(f"{text!r} is not a locator")
+ port = rest[1:]
+ else:
+ if ":" in loc:
+ host, port = loc.rsplit(":", 1)
+ check_host(host, text)
+ if port is not None and (not port.isdigit() or not 0 < int(port) < 65536):
+ raise IdentityError(f"{text!r} is not a locator: {port!r} is not a port")
+ return loc
+
+
+def check_host(host: str, given: Any = None) -> str:
+ given = host if given is None else given
+ labels = host.split(".")
+ if len(host) > 253 or not all(LABEL.match(label) for label in labels):
+ raise IdentityError(f"{given!r} is not a locator: {host!r} is not a host name")
+ if labels[-1] == "onion" and (len(labels) < 2 or len(labels[-2]) != 56):
+ raise IdentityError(f"{given!r} is not a locator: an onion address is 56 characters before .onion")
+ return host
+
+
+@dataclass(frozen=True)
+class Parsed:
+ form: str # "identity", "address" or "name"
+ id: str | None = None
+ locator: str | None = None
+ name: str | None = None
+ domain: str | None = None
+
+ def __str__(self) -> str:
+ if self.form == "name":
+ return f"{self.name}@{self.domain}"
+ return self.id + (f"@{self.locator}" if self.locator else "")
+
+
+def parse(text: Any) -> Parsed:
+ """Any of the three forms: ``pyr:<key>``, ``pyr:<key>@locator``, ``name@domain``."""
+ if not isinstance(text, str) or not text.strip():
+ raise IdentityError(f"{text!r} is not an identity, an address or a name")
+ text = text.strip()
+ if text.lower().startswith(PREFIX):
+ ident, _, loc = text.partition("@")
+ ident = normal(ident)
+ if not _:
+ return Parsed("identity", id=ident)
+ return Parsed("address", id=ident, locator=check_locator(loc))
+ local, at, domain = text.lower().rpartition("@")
+ if not at:
+ raise IdentityError(f"{text!r} is not an identity ({PREFIX}…) or a name (name@domain)")
+ if not LOCAL.match(local):
+ raise IdentityError(f"{text!r} is not a name: {local!r} is not lowercase letters, digits, dot, dash, underscore, at most 63")
+ return Parsed("name", name=local, domain=check_host(domain, text))
+
+
+# ---------------------------------------------------------------- signatures
+
+
+def _ed25519():
+ try:
+ from cryptography.hazmat.primitives.asymmetric import ed25519
+ except ImportError:
+ raise IdentityError(INSTALL) from None
+ 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("=")
+
+
+def _unb64(text: Any) -> bytes:
+ if not isinstance(text, str) or not re.fullmatch(r"[A-Za-z0-9_-]*", text):
+ raise IdentityError("not base64url")
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
+
+
+class Key:
+ """A private key, held in memory; ``id`` is its identity."""
+
+ def __init__(self, seed: bytes):
+ if len(seed) != 32:
+ raise IdentityError("an Ed25519 private key is 32 bytes")
+ self._private = _ed25519().Ed25519PrivateKey.from_private_bytes(seed)
+ self._seed = seed
+ from cryptography.hazmat.primitives import serialization as s
+ self.id = encode(self._private.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw))
+
+ @classmethod
+ def generate(cls) -> "Key":
+ return cls(os.urandom(32))
+
+ def sign(self, message: bytes) -> bytes:
+ return self._private.sign(message)
+
+ def __repr__(self) -> str:
+ return f"Key({short(self.id)})"
+
+ # A key file is JSON, written once, readable only by its owner.
+
+ def save(self, path: Path, *, kind: str = "root") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ data = json.dumps({"pyrtan-key": 1, "kind": kind, "id": self.id, "seed": _b64(self._seed)}, indent=2) + "\n"
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(data)
+ return path
+
+ @classmethod
+ def load(cls, path: Path) -> "Key":
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{path}: {e}") from None
+ if not isinstance(data, dict) or data.get("pyrtan-key") != 1:
+ raise IdentityError(f"{path} is not a pyrtan key file")
+ key = cls(_unb64(data.get("seed")))
+ if data.get("id") != key.id:
+ raise IdentityError(f"{path}: the id in the file is not the key's")
+ return key
+
+
+def verify(ident: str, message: bytes, signature: bytes) -> bool:
+ """Whether ``signature`` is ``ident``'s over ``message``."""
+ public = _ed25519().Ed25519PublicKey.from_public_bytes(decode(ident))
+ try:
+ public.verify(signature, message)
+ except Exception:
+ return False
+ return True
+
+
+# ---------------------------------------------------------------- documents
+
+
+def _plain(value: Any, where: str = "document") -> None:
+ if isinstance(value, float):
+ raise IdentityError(f"{where} holds a number with a fraction; documents hold strings, integers, lists and objects")
+ if isinstance(value, dict):
+ for k, v in value.items():
+ if not isinstance(k, str):
+ raise IdentityError(f"{where} has a key that is not a string")
+ _plain(v, f"{where}.{k}")
+ elif isinstance(value, list):
+ for i, v in enumerate(value):
+ _plain(v, f"{where}[{i}]")
+ elif not (value is None or isinstance(value, (str, int, bool))):
+ raise IdentityError(f"{where} holds a {type(value).__name__}")
+
+
+def signed_bytes(doc: dict[str, Any]) -> bytes:
+ """What a document's ``sig`` is over."""
+ body = {k: v for k, v in doc.items() if k != "sig"}
+ _plain(body)
+ return DOC_TAG + canonical(body)
+
+
+def sign_document(doc: dict[str, Any], key: Key) -> dict[str, Any]:
+ body = {k: v for k, v in doc.items() if k != "sig"}
+ return {**body, "sig": _b64(key.sign(signed_bytes(body)))}
+
+
+def check_signature(doc: Any, by: str) -> str | None:
+ """None if ``doc`` is signed by ``by``; otherwise what is wrong."""
+ if not isinstance(doc, dict) or "sig" not in doc:
+ return "not signed"
+ try:
+ signature = _unb64(doc["sig"])
+ message = signed_bytes(doc)
+ except IdentityError as e:
+ return str(e)
+ except ValueError:
+ return "the signature is not base64url"
+ return None if verify(by, message, signature) else f"the signature is not {short(by)}'s"
+
+
+def _int(doc: dict[str, Any], field: str) -> int:
+ value = doc.get(field)
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+ raise IdentityError(f"{field} is not a time (whole seconds since 1970)")
+ return value
+
+
+def _abilities(value: Any) -> list[str]:
+ if not isinstance(value, list) or not all(a in ABILITIES for a in value) or len(set(value)) != len(value):
+ raise IdentityError(f"may is a list of {', '.join(ABILITIES)}, each once")
+ return value
+
+
+# Device notes.
+
+
+def make_device(root: Key, device: str, may: list[str], *, label: str = "", issued: int | None = None, expires: int | None = None) -> dict[str, Any]:
+ issued = 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,
+ }
+ if note["device"] == root.id:
+ raise IdentityError("a root does not delegate to itself; it signs for itself")
+ _check_device_fields(note)
+ return sign_document(note, root)
+
+
+def _check_device_fields(note: dict[str, Any]) -> None:
+ if note.get("pyrtan-device") != 1:
+ raise IdentityError("not a device note (pyrtan-device: 1)")
+ normal(note.get("root"))
+ normal(note.get("device"))
+ if not isinstance(note.get("label", ""), str) or len(note.get("label", "")) > 64:
+ raise IdentityError("label is a string of at most 64 characters")
+ _abilities(note.get("may"))
+ issued, expires = _int(note, "issued"), _int(note, "expires")
+ if not issued < expires <= issued + YEAR:
+ raise IdentityError("a device note expires after it is issued, and within a year")
+
+
+def check_device(note: Any, *, root: str | None = None, now: float | None = None) -> str | None:
+ """None if the note holds (signed by its root, and by ``root`` if given, and current); otherwise why not."""
+ if not isinstance(note, dict):
+ return "not a device note"
+ try:
+ _check_device_fields(note)
+ except IdentityError as e:
+ return str(e)
+ if root is not None and note["root"].lower() != root.lower():
+ return f"the note is {short(note['root'])}'s, not {short(root)}'s"
+ problem = check_signature(note, note["root"])
+ if problem:
+ return problem
+ now = time.time() if now is None else now
+ if note["issued"] > now + WINDOW:
+ return "the note is issued in the future"
+ if note["expires"] <= now:
+ return "the note has expired"
+ return None
+
+
+# Identity documents.
+
+
+def make_identity(root: Key, *, kind: str = "person", routes: list[str] | None = None, devices: list[dict[str, Any]] | None = None,
+ revoked: list[str] | None = None, updated: int | None = None) -> dict[str, Any]:
+ doc = {
+ "pyrtan-identity": 1, "id": root.id, "kind": kind,
+ "routes": [check_locator(r) for r in routes or []],
+ "devices": list(devices or []), "revoked": [normal(r) for r in revoked or []],
+ "updated": int(time.time()) if updated is None else updated,
+ }
+ _check_identity_fields(doc)
+ return sign_document(doc, root)
+
+
+def _check_identity_fields(doc: dict[str, Any]) -> None:
+ if doc.get("pyrtan-identity") != 1:
+ raise IdentityError("not an identity document (pyrtan-identity: 1)")
+ ident = normal(doc.get("id"))
+ if doc.get("kind") not in KINDS:
+ raise IdentityError(f"kind is one of {', '.join(KINDS)}")
+ routes = doc.get("routes")
+ if not isinstance(routes, list) or len(routes) > MAX_ROUTES:
+ raise IdentityError(f"routes is a list of at most {MAX_ROUTES} locators")
+ for r in routes:
+ if check_locator(r) != r:
+ raise IdentityError(f"route {r!r} is not lowercase")
+ revoked = doc.get("revoked")
+ if not isinstance(revoked, list) or any(normal(r) != r for r in revoked):
+ raise IdentityError("revoked is a list of lowercase identities")
+ devices = doc.get("devices")
+ if not isinstance(devices, list) or len(devices) > MAX_DEVICES:
+ raise IdentityError(f"devices is a list of at most {MAX_DEVICES} device notes")
+ seen = set()
+ for note in devices:
+ if not isinstance(note, dict):
+ raise IdentityError("devices holds something that is not a device note")
+ _check_device_fields(note)
+ if note["root"] != ident:
+ raise IdentityError(f"device {short(note['device'])} is delegated by {short(note['root'])}, not this identity")
+ if note["device"] in seen:
+ raise IdentityError(f"device {short(note['device'])} is listed twice")
+ seen.add(note["device"])
+ _int(doc, "updated")
+
+
+def check_identity(doc: Any, *, ident: str | None = None, now: float | None = None) -> str | None:
+ """None if the document holds (well formed, signed by its own id, every note signed by it, not from the future)."""
+ if not isinstance(doc, dict):
+ return "not an identity document"
+ try:
+ _check_identity_fields(doc)
+ except IdentityError as e:
+ return str(e)
+ if ident is not None and doc["id"] != ident.lower():
+ return f"the document is {short(doc['id'])}'s, not {short(ident)}'s"
+ problem = check_signature(doc, doc["id"])
+ if problem:
+ return problem
+ for note in doc["devices"]:
+ problem = check_signature(note, doc["id"])
+ if problem:
+ return f"device {short(note['device'])}: {problem}"
+ if doc["updated"] > (time.time() if now is None else now) + WINDOW:
+ return "the document is from the future"
+ return None
+
+
+def newest(docs: list[Any], *, ident: str | None = None, now: float | None = None) -> dict[str, Any] | None:
+ """Of several copies, the one with the highest ``updated`` whose checks hold."""
+ good = [d for d in docs if check_identity(d, ident=ident, now=now) is None]
+ return max(good, key=lambda d: d["updated"], default=None)
+
+
+def device_status(doc: dict[str, Any], device: str, *, now: float | None = None) -> str:
+ """``ok``, ``expired``, ``revoked`` or ``unknown``, for a device under a checked identity document."""
+ device = device.lower()
+ if device in doc["revoked"]:
+ return "revoked"
+ for note in doc["devices"]:
+ if note["device"] == device:
+ return "ok" if note["expires"] > (time.time() if now is None else now) else "expired"
+ return "unknown"
+
+
+def with_device(doc: dict[str, Any], root: Key, note: dict[str, Any]) -> dict[str, Any]:
+ """A new document, signed, with ``note`` in place of any earlier note for the same device."""
+ devices = [n for n in doc["devices"] if n["device"] != note["device"]] + [note]
+ revoked = [r for r in doc["revoked"] if r != note["device"]]
+ return _reissue(doc, root, devices=devices, revoked=revoked)
+
+
+def with_revoked(doc: dict[str, Any], root: Key, device: str) -> dict[str, Any]:
+ device = normal(device)
+ devices = [n for n in doc["devices"] if n["device"] != device]
+ revoked = doc["revoked"] + ([device] if device not in doc["revoked"] else [])
+ return _reissue(doc, root, devices=devices, revoked=revoked)
+
+
+def with_routes(doc: dict[str, Any], root: Key, routes: list[str]) -> dict[str, Any]:
+ return _reissue(doc, root, routes=routes)
+
+
+def _reissue(doc: dict[str, Any], root: Key, **changes: Any) -> dict[str, Any]:
+ if doc["id"] != root.id:
+ raise IdentityError(f"the document is {short(doc['id'])}'s; this key is {short(root.id)}")
+ fields = {k: doc[k] for k in ("kind", "routes", "devices", "revoked")} | changes
+ # Strictly newer than the copy it replaces, or a holder would keep the old one.
+ return make_identity(root, updated=max(int(time.time()), doc["updated"] + 1), **fields)
+
+
+# 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``)."""
+ 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)
+ return sign_document(claim, key)
+
+
+def check_name(claim: Any, *, name: str | None = None) -> str | None:
+ """None if the claim holds, and is for ``name`` if given; otherwise why not."""
+ if not isinstance(claim, dict) or claim.get("pyrtan-name") != 1:
+ return "not a name claim (pyrtan-name: 1)"
+ try:
+ normal(claim.get("id"))
+ _int(claim, "issued")
+ stated = parse(f"{claim.get('name')}@{claim.get('domain')}")
+ except IdentityError as e:
+ return str(e)
+ if stated.form != "name" or (stated.name, stated.domain) != (claim["name"], claim["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 claim is for {stated}, not {name}"
+ return check_signature(claim, claim["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):
+ if "pyrtan-identity" in doc:
+ return "identity", check_identity(doc, now=now)
+ if "pyrtan-device" in doc:
+ 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"
+
+
+# ---------------------------------------------------------------- signed requests
+#
+# A request made as an identity carries X-Pyrtan-Key (the signing key, a
+# root or a device), X-Pyrtan-Date, X-Pyrtan-Key-Signature (Ed25519 over the
+# message below) and, for a device, X-Pyrtan-Device (its note, as base64url
+# canonical JSON). The message is the HMAC one with a domain string and the
+# *audience*, the receiving hub's own id, in front: a request signed for one
+# hub cannot be replayed at another.
+
+
+def request_message(audience: str, method: str, path: str, date: str, body: Any) -> bytes:
+ digest = hashlib.sha256(canonical(body) if body is not None else b"").hexdigest()
+ return "\n".join([REQUEST_TAG, audience.rstrip("/"), method.upper(), path, date, digest]).encode("utf-8")
+
+
+def sign_request(key: Key, audience: str, method: str, path: str, body: Any, *, note: dict[str, Any] | None = None,
+ date: float | None = None) -> dict[str, str]:
+ """The headers that make a request ``key``'s; ``note`` when ``key`` is a device."""
+ date_text = str(int(time.time() if date is None else date))
+ headers = {
+ "X-Pyrtan-Key": key.id, "X-Pyrtan-Date": date_text,
+ "X-Pyrtan-Key-Signature": _b64(key.sign(request_message(audience, method, path, date_text, body))),
+ }
+ if note is not None:
+ if note.get("device") != key.id:
+ raise IdentityError(f"the note is for {short(note.get('device', '?'))}, not this key")
+ headers["X-Pyrtan-Device"] = _b64(canonical(note))
+ return headers
+
+
+@dataclass(frozen=True)
+class Signer:
+ """Who signed a request: the key, the root it speaks for, and the device note if it is a device."""
+ key: str
+ root: str
+ note: dict[str, Any] | None = None
+
+
+def has_key_signature(headers: dict[str, str] | None) -> bool:
+ return any(k.lower() == "x-pyrtan-key-signature" for k in (headers or {}))
+
+
+def verify_request(audience: str, method: str, path: str, body: Any, headers: dict[str, str] | None, *,
+ now: float | None = None) -> Signer:
+ """The signer of a request, or IdentityError saying why there is none. Checks the
+ signature, the time, and the device note (signed by its root, current, for this key);
+ whether the root may do anything is the caller's question."""
+ h = {k.lower(): v for k, v in (headers or {}).items()}
+ key, date, signature = h.get("x-pyrtan-key", ""), h.get("x-pyrtan-date", ""), h.get("x-pyrtan-key-signature", "")
+ if not key or not signature:
+ raise IdentityError("no key signature")
+ key = normal(key)
+ if not date.isdigit():
+ raise IdentityError("no date")
+ now = time.time() if now is None else now
+ if abs(now - int(date)) > WINDOW:
+ raise IdentityError("signature too old")
+ try:
+ raw = _unb64(signature)
+ except (IdentityError, ValueError):
+ raise IdentityError("the signature is not base64url") from None
+ if not verify(key, request_message(audience, method, path, date, body), raw):
+ raise IdentityError(f"the signature is not {short(key)}'s, for {audience}")
+ if "x-pyrtan-device" not in h:
+ return Signer(key, key)
+ try:
+ note = json.loads(_unb64(h["x-pyrtan-device"]).decode("utf-8"))
+ except (IdentityError, ValueError):
+ raise IdentityError("X-Pyrtan-Device is not a base64url note") from None
+ problem = check_device(note, now=now)
+ if problem:
+ raise IdentityError(f"device note: {problem}")
+ if note["device"] != key:
+ raise IdentityError(f"the device note is for {short(note['device'])}, not the signing key")
+ return Signer(key, note["root"], note)
+
+
+# ---------------------------------------------------------------- where the CLI keeps them
+
+
+def home() -> Path:
+ """``$PYRTAN_IDENTITY``, else ``$XDG_CONFIG_HOME/pyrtan/identity``, else ``~/.config/pyrtan/identity``."""
+ if os.environ.get("PYRTAN_IDENTITY"):
+ return Path(os.environ["PYRTAN_IDENTITY"])
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
+ return base / "pyrtan" / "identity"
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/names.py b/src/pyrtan/names.py new file mode 100644 index 0000000..66fff3d --- /dev/null +++ b/src/pyrtan/names.py
@@ -0,0 +1,304 @@
+"""Names: ``alice@example.org`` resolved to an identity, pinned, and served.
+
+A name resolves by fetching ``https://<domain>/.well-known/pyrtan/name/<local>.json``,
+a claim signed by the identity it names. The resolver checks that the
+claim is for the name it asked and that the signature holds, then
+compares the answer with the *pin*: the identity the name resolved to
+the first time. A different answer later is never followed; it is shown
+beside 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.
+: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.
+
+This module never fetches an ``.onion``: that needs Tor, which pyrtan does
+not reach yet. The design is ``_meta/identity.md`` §6.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import time
+from pathlib import Path
+from typing import Any, Callable
+
+from . import identity
+from .catalog import Problem
+from .identity import IdentityError
+
+WELL_KNOWN = ".well-known/pyrtan"
+NAMES_DIR = "site/names"
+GITHUB_LOGIN = re.compile(r"^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$")
+
+Fetch = Callable[[str], tuple[int, Any]]
+
+
+# ---------------------------------------------------------------- resolving
+
+
+def _fetch(url: str) -> tuple[int, Any]:
+ from .service import http_json
+ return http_json("GET", url, timeout=10.0)
+
+
+def _base(host: str) -> str:
+ host = identity.check_locator(host)
+ if host.split(":")[0].endswith(".onion"):
+ raise IdentityError(f"{host} is an onion service: reaching it needs Tor, which pyrtan does not reach yet")
+ return f"https://{host}"
+
+
+def name_url(name: str) -> str:
+ parsed = identity.parse(name)
+ if parsed.form != "name":
+ raise IdentityError(f"{name!r} is not a name (name@domain)")
+ return f"{_base(parsed.domain)}/{WELL_KNOWN}/name/{parsed.name}.json"
+
+
+def identity_url(ident: str, host: str) -> str:
+ return f"{_base(host)}/{WELL_KNOWN}/id/{identity.normal(ident)[len(identity.PREFIX):]}.json"
+
+
+def resolve(name: str, *, fetch: Fetch | None = None) -> dict[str, Any]:
+ """The checked claim ``name``'s domain serves; IdentityError says why there is none."""
+ url = name_url(name)
+ try:
+ status, claim = (fetch or _fetch)(url)
+ except Exception as e:
+ raise IdentityError(f"{url}: {e}") from None
+ if status == 404:
+ raise IdentityError(f"{identity.parse(name)} has no claim at {url}")
+ if status != 200:
+ raise IdentityError(f"{url} answered {status}")
+ problem = identity.check_name(claim, name=name)
+ if problem:
+ raise IdentityError(f"{url}: {problem}")
+ return claim
+
+
+def fetch_identity(ident: str, host: str, *, fetch: Fetch | None = None) -> dict[str, Any] | None:
+ """The identity document ``host`` serves for ``ident``, if it serves one that holds."""
+ try:
+ status, doc = (fetch or _fetch)(identity_url(ident, host))
+ except Exception:
+ return None
+ if status != 200 or identity.check_identity(doc, ident=ident) is not None:
+ return None
+ return doc
+
+
+# ---------------------------------------------------------------- pins
+
+
+class Pins:
+ """Name -> the identity it first resolved to, in a JSON file."""
+
+ def __init__(self, path: Path):
+ self.path = path
+ self.names: dict[str, dict[str, Any]] = {}
+ if path.exists():
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{path}: {e}") from None
+ if not isinstance(data, dict) or data.get("pyrtan-pins") != 1:
+ raise IdentityError(f"{path} is not a pyrtan pins file")
+ self.names = data.get("names", {})
+
+ def get(self, name: str) -> dict[str, Any] | None:
+ return self.names.get(str(identity.parse(name)))
+
+ def compare(self, name: str, ident: str) -> str:
+ """``new``, ``same`` or ``changed``."""
+ pin = self.get(name)
+ return "new" if pin is None else "same" if pin["id"] == identity.normal(ident) else "changed"
+
+ def pin(self, name: str, ident: str, *, now: int | None = None) -> None:
+ now = int(time.time()) if now is None else now
+ key, ident = str(identity.parse(name)), identity.normal(ident)
+ old = self.names.get(key)
+ first = old["first"] if old and old["id"] == ident else now
+ self.names[key] = {"id": ident, "first": first, "seen": now}
+
+ def forget(self, name: str) -> bool:
+ return self.names.pop(str(identity.parse(name)), None) is not None
+
+ def save(self) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ temp = self.path.with_suffix(".json.tmp")
+ temp.write_text(json.dumps({"pyrtan-pins": 1, "names": self.names}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ temp.replace(self.path)
+
+
+# ---------------------------------------------------------------- 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
+ label = directory.as_posix() if label is None else label
+
+ def rel(p: Path) -> str:
+ return f"{label}/{p.name}"
+
+ 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"))
+ 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}"))
+ continue
+ if path.name.endswith(".id.json"):
+ docs[path.name[: -len(".id.json")]] = data
+ continue
+ 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 problem:
+ problems.append(Problem(rel(path), problem))
+ continue
+ claims[stem] = data
+ for stem, doc in 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"))
+ continue
+ problem = identity.check_identity(doc, ident=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
+
+
+def build_wellknown(directory: Path, out: Path, domain: str) -> tuple[int, list[Problem]]:
+ """Write ``out/.well-known/pyrtan/``: ``name/<name>.json`` per claim, ``id/<56>.json``
+ per identity document, and ``names.json`` listing every claim. Returns how
+ many names were written and the problems; nothing that fails a check is served."""
+ claims, docs, problems = read_directory(directory, domain)
+ root = out / WELL_KNOWN
+ if root.exists():
+ shutil.rmtree(root)
+ (root / "name").mkdir(parents=True)
+ (root / "id").mkdir()
+ for stem, claim in claims.items():
+ (root / "name" / f"{stem}.json").write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ newest: dict[str, dict[str, Any]] = {}
+ for doc in docs.values():
+ if doc["id"] not in newest or doc["updated"] > newest[doc["id"]]["updated"]:
+ newest[doc["id"]] = doc
+ for ident, doc in newest.items():
+ (root / "id" / f"{ident[len(identity.PREFIX):]}.json").write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ index = {"pyrtan-names": 1, "domain": domain, "names": claims}
+ (root / "names.json").write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
+ return len(claims), problems
+
+
+# ---------------------------------------------------------------- pyrtan.com's gate
+
+
+def gate(root: Path, *, changed: list[str] | None = None, author: str | None = None, author_id: int | None = None,
+ base: dict[str, bytes] | None = None) -> list[Problem]:
+ """pyrtan.com's rule for ``site/names/``, on top of every claim and document holding:
+ a name is a GitHub login, at most one per GitHub account (its numeric ``github`` id).
+ Given the pull request's ``changed`` paths, ``author`` (login), ``author_id`` and
+ ``base`` (``path -> bytes`` on the base branch), a new name must be the author's own
+ login and carry the author's id, and a name may be changed or removed only by the
+ account whose id it carried before."""
+ domain = (root / "site" / "CNAME").read_text(encoding="utf-8").strip() if (root / "site" / "CNAME").is_file() else "pyrtan.com"
+ directory = root / NAMES_DIR
+ claims, _, problems = read_directory(directory, domain, label=NAMES_DIR)
+ holders: dict[int, str] = {}
+ for stem, claim in claims.items():
+ where = f"{NAMES_DIR}/{stem}.json"
+ if not GITHUB_LOGIN.match(stem):
+ problems.append(Problem(where, "a name at pyrtan.com is a GitHub login"))
+ github = claim.get("github")
+ if not isinstance(github, int) or isinstance(github, bool) or github <= 0:
+ problems.append(Problem(where, "a claim at pyrtan.com carries \"github\": your numeric GitHub user id"))
+ continue
+ if github in holders:
+ problems.append(Problem(where, f"GitHub account {github} already holds {holders[github]}; one name per account"))
+ holders[github] = stem
+ if not changed:
+ return problems
+ base = base or {}
+ login = (author or "").lstrip("@").lower()
+ for c in changed:
+ if not c.startswith(f"{NAMES_DIR}/") or c.endswith("/README.md"):
+ continue
+ name = Path(c).name
+ stem = name[: -len(".id.json")] if name.endswith(".id.json") else name[: -len(".json")] if name.endswith(".json") else name
+ claim_path = f"{NAMES_DIR}/{stem}.json"
+ before = _github_of(base.get(claim_path))
+ if author_id is None:
+ problems.append(Problem(c, "the gate needs the pull request author's numeric id (--author-id) to judge a change to a name"))
+ continue
+ if before is not None:
+ if before != author_id:
+ problems.append(Problem(c, f"{stem} belongs to GitHub account {before}; only that account changes or removes it (a maintainer may merge over this)"))
+ elif stem in claims and claims[stem].get("github") != before:
+ problems.append(Problem(c, f"{stem} stays with GitHub account {before}; to move it, remove it and add your new login's name"))
+ continue
+ # A new name.
+ if stem not in claims:
+ continue # a stray file, already reported
+ if stem != login:
+ problems.append(Problem(c, f"a new name is your own GitHub login: {stem!r} is not {login or 'the author'}'s"))
+ if claims[stem].get("github") != author_id:
+ problems.append(Problem(c, f"the claim says github {claims[stem].get('github')}; the pull request's author is {author_id}"))
+ return problems
+
+
+def _github_of(raw: bytes | None) -> int | None:
+ if raw is None:
+ return None
+ try:
+ github = json.loads(raw.decode("utf-8")).get("github")
+ except (ValueError, AttributeError):
+ return None
+ return github if isinstance(github, int) and not isinstance(github, bool) else 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"
+ 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)
+ written = [directory / f"{claim['name']}.json"]
+ written[0].write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ if doc is not None:
+ written.append(directory / f"{claim['name']}.id.json")
+ written[1].write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ return written
+
+
+def github_id(login: str, *, fetch: Fetch | None = None) -> int:
+ """A GitHub login's numeric user id, from GitHub's public API."""
+ url = f"https://api.github.com/users/{login}"
+ try:
+ status, body = (fetch or _fetch)(url)
+ except Exception as e:
+ raise IdentityError(f"{url}: {e}; pass --github YOUR-NUMERIC-ID") from None
+ 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"]
diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index de3b7c6..492ef28 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -23,6 +23,13 @@ the connect box, kept in the browser's local storage, the token sent as a
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
+anything but localhost has no WebCrypto, holds no key, and says so.
""" from __future__ import annotations
@@ -37,6 +44,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">
@@ -49,6 +58,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 } }
@@ -135,6 +145,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>
@@ -227,6 +238,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>

The diff is cut here; the rest is in the clone.