# Users and what they may do Who the users are, and what each may do, is the swarm's: a registry in the document, the same on every node. How a user proves who it is stays each node's: its `--htpasswd`, or a reverse proxy naming the user (`--user-header`, [`trust.md`](trust.md)). ## The registry `_continuum/users/`, one entry per user. It is protocol data: every member holds it, a remake's snapshot carries it into the next epoch, and it holds no secrets (no password hashes: every member reads the document). ```json { "since": 1762400000000, "by": "continuum:3f…", "caps": { "8c41e2…": { "path": [], "rights": ["read", "write"], "by": "continuum:3f…", "since": 1762400000000 } }, "disabled": false } ``` * **Names** are what `--htpasswd` and the proxy give: 1 to 64 of `A-Z a-z 0-9 . _ @ -`. * **Capabilities** (`caps`): each a path, as segments as the store's API takes them, and the rights it gives there and below: `read`, `write`. A capability covers its path segment by segment (`servers/eu` covers `servers/eu/0`, not `servers/europe`). One outside protocol data never covers protocol data: `[]` is all of the swarm's data, `["_continuum"]` protocol data, managing users included (`_continuum/users`). They are kept by a random ID, so two capabilities granted at once both hold, and revoking one removes it alone. Each names who granted it (`by`: a user as `user:`, or the node that recorded it) and when (`since`). * **`disabled`**: the entry stays (who wrote what stays readable), and gives nothing. An entry that doesn't read as above gives less, never more: a capability that doesn't read is left out, and anything but `false` disables. * **`keys`**: the user's SSH keys, each by a name of its choosing, as an `authorized_keys` line has it (`"laptop": "ssh-ed25519 AAAA…"`; Ed25519 only): what it signs values with (Values signed by their users, below) and logs in with. A user manages its own, whatever else it may do: `GET /auth/keys`, `PUT /auth/keys/` with the line, `DELETE /auth/keys/`; under a strict policy, by signing its entry with only its keys changed (`continuum-signed key-add laptop ~/.ssh/id_ed25519.pub`, `key-remove laptop`), with a key it holds already. Every node takes that from the user itself, as from one who may manage users, but never its last key removed (`409`): one who may manage users may. ## A new swarm A swarm gets a registry from its start if its genesis node is given its first users, `--bootstrap-users`: it records them, with the swarm ID, in its first change, each with everything (`[]` and `["_continuum"]`). Other users, of its `--htpasswd` or any node's, may do nothing until added (the genesis node warns of those of its `--htpasswd`). With `--bootstrap-keys alice=ssh-ed25519\ AAAA…\ laptop` it records their keys too, so they log in by key and sign values from the start: a swarm whose first user then writes the policy (Values signed by their users, below) is strict from its start. Without it, the swarm has no registry: its users are all alike (A swarm without a registry, below), until someone gives it one. `--protocol-writers` counts in both: without a registry, as who may write protocol data; with one, to set it right should nobody be left who may manage users (Keeping the swarm manageable, below). ## At the HTTP API Every request is checked against the registry, as the node serving it holds it: a change to it counts on a node as soon as it has reached it. * **The store** (`/store`, `/stream`): writing a path (`PUT`, `POST`, `DELETE`) takes `write` on it, or, inside a list or text, on the list, as every node judges the change (a list's items have no paths of their own, Changes judged on every node, below); `?prune=true` deletes maps left empty only up to the highest one the user may write. Reading takes `read`: of a place the user may read only partly, it sees the keys on the way to what it may read, as a directory one may pass through but not list (a capability on `servers/eu` shows `servers` with `eu` alone in it), and nothing else; a list on the way is left out whole, as its items have no paths of their own. Native forms (`text/plain` of a map, a counter as its own type) only of what it may read whole. What a write answers with is what was written. A stream shows what the user could read when it opened. * **Protocol data** is in the store too, under `_continuum`: `read` and `write` on it as on any path. * **Looking at the node** (`/admin/stats`, `GET /peers`, the dashboard): any user with a capability. The dashboard's document box is read through the store, as the user may; the page is told only of changes where its user may read. * **Remakes** (`/admin/remakes`): `read` on `_continuum/remakes` to look, `write` to propose or abort one. **Forgetting a peer** (`DELETE /peers/…`): `write` on `_continuum/forgotten`. * **Everything else an operator does** (`/admin/config`, `/admin/log`, `/admin/document`, `/admin/changes`, `/admin/epochs`, `/admin/quarantine`, `/debug`): `read` on `_continuum` to look, `write` on it to act. A user the node lets in but the registry doesn't have may do nothing (`403`). `[]` and `["_continuum"]` are two capabilities on purpose: protocol data acts on the whole swarm (its members, its remakes, its users), so it is given apart, never by accident with the data. A user who may do both has both. ## Tokens A user logs in on any node, by what that node checks (its `--htpasswd`, or a proxy's `--user-header`), and gets a token every node of the swarm takes, sent as `Authorization: Bearer ` instead of a password: ```sh curl -u alice -X POST http://localhost:3000/auth/login # {"token": "ct1.…", …} curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store ``` * **Who signs it**: the node it logged in on, with its node key. Every node takes a token signed by a member of the swarm (the member list gives the keys: a node ID is its key), and none signed by another or since forgotten. * **What it says**: who, how it proved it, when it ends, and optionally less than the user may do (`limit`, capabilities as `POST /admin/users/{name}` takes them): never what the user may do. Every node judges a request by the registry it holds then, so a capability granted or revoked counts on a node as soon as the change reaches it. * **How long**: `--token-lifetime` (12 hours), refreshed (`POST /auth/refresh`, on any node, which signs the new one) until `--session-lifetime` (7 days) after the login; then the user logs in again. A refreshed token may say less (`limit`), never more, and live less (`lifetime`, in seconds), and then ends its session with it: nobody refreshes it past its end. A dashboard opening another member's hands it one reading the member list only, for five minutes ([`dashboard.md`](dashboard.md)). * **Revoking**: a user disabled or removed is refused at once; all of a user's tokens, by the user (`POST /auth/logout`) or by one who may manage users (`DELETE /admin/users/{name}/tokens`), which sets `_continuum/logouts/`: tokens issued before aren't taken any more. A token of a node banned or forgotten by the swarm isn't taken either. In a swarm without a registry, a token is taken until it expires. The user a logout names is its node's word, and a logout no deciding path, even under a strict policy: any member could log any user out. It ends sessions; and a value there that isn't a time, or one far ahead, refuses every token of the user, its new ones too, until it is replaced: by the user logging out again by password (`POST /auth/logout`), or by one who manages users (`DELETE /admin/users/{name}/tokens`). * **What it means to trust**: any member could sign a token for any user, as it could write anything as anyone ([`trust.md`](trust.md)). A token is a bearer's: whoever holds it is the user until it ends, so it goes over TLS only, as passwords do. A user with keys in its entry (`keys`, The registry, above) logs in on any node without a password: it asks for a challenge, signs it with one of its keys, and gets the same token (`login: "key"`). ```sh c=$(curl -s -X POST localhost:3000/auth/challenge | jq -r .challenge) printf %s "$c" | ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n continuum-login > sig jq -n --arg c "$c" --rawfile s sig '{user: "alice", challenge: $c, signature: $s}' | curl -s -X POST localhost:3000/auth/login/key -H 'Content-Type: application/json' -d @- ``` A challenge is the node's, signed by it, valid for five minutes (`--challenge-lifetime`), and taken once (the node remembers which in memory: after a restart, one captured in its five minutes could be taken once more; it goes over TLS, as passwords do). A user logs in by key at most 100 times on a node within those minutes (`--key-logins-per-user`), so one holding a key can't keep the others out; the signature must be by one of the user's keys, as the registry the node holds then says, and the user enabled (`401` if not, saying why). A swarm without a registry has no keys (`409`). A browser keeps a token as its session: `POST /auth/session`, with a token, answers as `/auth/refresh` does and sets a cookie holding the new one (`HttpOnly`, `SameSite=Strict`: no script reads it, no other site's page sends it), which lets in requests without `Authorization` that read (`GET`, `HEAD`) or renew or end the session; one that changes anything brings the token itself, which no other page, of another site or of another port of the same host, can make a browser send. `DELETE /auth/session` forgets it. The login page (`/login`, without authentication, with the dashboard) begins one by password or from a token pasted, from a login by key, say; the dashboard renews it while open ([`dashboard.md`](dashboard.md)). `GET /auth/whoami` says the swarm, who a request's user is, how it proved it, and what it may do. Pages of other origins, the dashboards of other members, may call a node with a token: its own links (`/admin/dashboard/links/own`, and its events) and messages (`/admin/dashboard/messages/own/events`), `/auth/refresh` and `/auth/whoami`, nothing else. A node allows the origins of the dashboards its member list names (`--dashboard-url`) and those of `--cors-origins`; without credentials (no cookie or password crosses origins: the page sends its token). ## Managing users Through `/admin/users` ([`openapi.yaml`](openapi.yaml), Users): looking needs `read` on `_continuum/users`, changing `write` on it. Each change is one change to the document, logged and named by who made it. `POST /admin/users` adds several at once (below). ```sh curl -u alice http://localhost:3000/admin/users # who, and what each may do curl -u alice -X POST -H 'Content-Type: application/json' \ --data '{"caps": [{"path": ["servers", "eu"], "rights": ["read"]}]}' \ http://localhost:3000/admin/users/bob # add bob, reading servers/eu curl -u alice -X POST -H 'Content-Type: application/json' \ --data '{"path": [], "rights": ["read", "write"]}' \ http://localhost:3000/admin/users/bob/caps # grant: answers its ID curl -u alice -X DELETE http://localhost:3000/admin/users/bob/caps/ID # revoke it curl -u alice -X PUT -H 'Content-Type: application/json' --data true \ http://localhost:3000/admin/users/bob/disabled # disable bob ``` Disabling a user keeps who wrote what readable; removing it (`DELETE`) doesn't. The registry is protocol data in the store too, so a user who may write `_continuum/users` may also edit its entries there directly; the same checks apply. ## A swarm without a registry In a swarm whose document has no registry (`_continuum/users`), started without `--bootstrap-users`, every user a node lets in may read and write the swarm's data and use the admin endpoints, and only those in `--protocol-writers` write protocol data. It gets a registry once a protocol writer adds its users: ```sh curl -u alice -X POST -H 'Content-Type: application/json' --data '{ "alice": {"caps": [{"path": [], "rights": ["read", "write"]}, {"path": ["_continuum"], "rights": ["read", "write"]}]}, "bob": {"caps": [{"path": [], "rights": ["read", "write"]}]} }' http://localhost:3000/admin/users ``` From that change on, every node serves only the users the registry has, so the caller must be among them, with `write` on `_continuum` (else `409`): list every node's users first. ## Keeping the swarm manageable A user may not lock itself out: a write that would keep its writer from managing users (enabled, with `write` on `_continuum/users`) when it could, deleting, disabling or narrowing its own entry, is refused (`409`), and nothing of it is written; so is one that would remove the registry (`_continuum/users`). Others are not kept from removing the last user who may manage users: each node can only judge what it holds, and two such writes on two nodes at once would leave nobody once they merge anyway. Then, while nobody may manage users, the users in a node's `--protocol-writers` may do everything there again, to set it right. ## Changes judged on every node What a user may do is checked where it writes, by the node it writes through. A swarm can also have every node judge every change it receives before applying it, so a node whose operator lets anyone write (or that claims another's users) gets nothing past the others: the policy, at `_continuum/access/policy`, written by a user who may write protocol data. ```sh curl -X PUT localhost:3000/store/_continuum/access/policy \ -H 'Content-Type: application/json' -d '{"version": 1, "signatures": "optional"}' ``` A change is judged by what it touches, by the node that committed it, and by the user it names, as the document stood when it was written (at its dependencies): a revocation since refuses nothing it allowed then, and every node judges alike. One written before the policy (at its dependencies) is judged by none: history from before stays as it is, wherever it comes from later (a joining node catching up, say). * A member's entry (`_continuum/members/`) is written by that member, and by a user who may write there, all of it (an operator, through the store, setting addresses or the dashboard right); removed also by one who forgets the member in the same change (`DELETE /peers/{pk}`, with `_continuum/forgotten`). Its votes by that member only. * Epochs and the swarm ID (`_continuum/epochs`, `_continuum/swarm`) by an epoch's first change only (the genesis change, a remake's snapshot). * A remake's record and its proposal by whichever member proposes, where there are none; its decision by the coordinator its proposal names, or a user who may write there (an operator aborting an expired remake through any member, `DELETE /admin/remakes/{id}`, or superseding it); an abort asked for by any member, which the coordinator applies. The record of the remake that made an epoch, carried into it whole, by the coordinator its snapshot names, where there is none. Replacing or removing a record or its proposal, by a user who may write there. * What holds the members' entries, votes and remake records (`_continuum/members`, `_continuum/votes`, `_continuum/remakes`) made by whichever member first writes in it, where there is none; replaced or removed by a user who may write there only. * A user's tokens revoked by that user too (logging out), and by one who manages users. * A member's forget record cancelled by that member too (forgotten while alive). * Everything else, protocol data or not, by a user the registry lets write there. * A map made on the way to what a change writes, where there was none (`servers`, for a first write at `servers/eu/0`), as what it writes within it. * A node that isn't a member may only register itself; a banned node nothing (Banning a node, below). So a node registers before anything else it writes, through the API or its own: a change refused stays out with every change built on it. A node judges each change it makes the same way before it leaves, its own writes and those through the API alike: one every node would refuse is rolled back, never written, and the request refused (`403`, saying why). A rollback costs a copy of the document, so a user with more than ten such writes within a minute (`--refused-writes-per-minute`) has its writes answered `429`, untried, until the oldest leave the minute; and past six times as many on the node in all, so does every user with one refused within the minute (users none of whose writes were refused write on). So a write the API's checks let through (they look at the path asked for, the rules at everything the change does) can't cut its node off. With `"signatures": "required"`, a change also needs a signature by the node it names ([`api.md`](api.md), Signed changes): every node then signs every change it makes, and one no signature vouches for yet waits, with whatever builds on it, until one does (they come with the changes, or within a second on their own). A signature vouches for its change and the node's changes it builds on, no others: a change made under another node's actor is forged, refused, and logged as its sender's (`A peer brought changes forged`). A member can then no longer write as another member; the user a change names is still its node's word. In a swarm without a registry, whatever a user would need, any member may. A refused change stays out, and so does whatever builds on it; the node logs it (`Refused a change`) and lists the last thousand (`GET /admin/refused`). Offered again, as its writer's node keeps offering what is never taken, it is refused by its hash, not judged or logged again, and a peer offering nothing else is answered later each time, up to a minute apart. With signatures required, a member whose own change broke a rule (its signature proves it its own) is refused as a whole: every node sends it nothing more, as all it commits from then on builds on what was refused, and logs it once. It shows in `GET /peers` (`refused`, with the change and why), in `/admin/stats` (`sync.refusedPeers`) and on the dashboard. A node that only passed a refused change on isn't: the committer is, whoever brought it. It lasts until the member has another document (it dropped what was refused, or rejoined), a change of its is taken again, or the epoch changes; forgetting or banning it is the operators'. A node takes a change the others refuse only if it didn't judge it as they do: under a policy it didn't know yet (it receives without judging then), or before the policy came. Everything it writes from then on builds on that change, and is refused too. So a node refusing changes a peer brought tells it which broke a rule (REFUSALS, at most every 30 seconds). The peer doesn't take the word for it: it judges each it holds itself, by its rules as they are now. If they refuse one too, it drops it with everything built on it, its document becoming what it would be without them, and puts its own writes among them in the quarantine (`GET /admin/quarantine`), to be replayed or discarded, and the others' among the refused (`GET /admin/refused`, "taken unjudged, dropped"); what it writes from then on goes everywhere. A drop taking more than 100 changes, or more than ten and a tenth of the document, waits until a second member says so too, where the node has another neighbour to hear it from: its own rules and one peer's word could both be off (logged as an error, counted in `/admin/stats` as `sync.dropsHeld`). It guards against one peer, not two members saying the same; and never makes a node drop what its own rules take. If its rules take the change, the two policies disagree, which it logs. A change whose dependencies haven't come yet waits for them. A node judges what it receives whether its document has a policy yet or not, each change by the policy it was written under: a joining node judges its first sync, the policy's own change and everything after it, as every other node does, and waits for the signatures of what was written under a policy requiring them. While neither its document nor what it receives holds a policy, it judges only that a root is the epoch's, and nothing costs more than applying the changes. A policy removed, or replaced by one the node doesn't know, still judges what was written under it and comes late, as long as the node runs (after a restart, a document without a policy is judged as one that never had one). An epoch's root is pinned. A remake's is its epoch's ID, the hash of its snapshot. The first epoch's is its genesis change, which its genesis node logs (`The swarm's root`) and `GET /admin/epochs` shows (`root`). Every node names its root in its handshakes: a node whose document starts from another root of the same epoch, or that was given another (`--genesis-root`), refuses the handshake (another history under the swarm's ID: a forged root, or a genesis started again). A node joining with an empty document takes as its root only the one it was given (`--genesis-root`), or else the one its peers name, and none while they name different ones; and none before each of its seeds named one, or ten seconds passed since the first did. So without `--genesis-root`, the root is the one the seeds that answer within that wait agree on: one peer hands a joiner another root only if it is all the joiner hears by then; with `--genesis-root`, never. What it doesn't do yet: * **Prove the user.** A change is its node's (with signatures required, proven) and its claimed user's: a member could claim a user its operator doesn't have. A node that doesn't know the policy (another version, or a setting it lacks) takes no writes through the API (`409`) and receives without judging, until it is upgraded. ## Banning a node A node can be banned: `_continuum/banned/`, written by a user who may write there (protocol data). Every node then leaves its handshakes unanswered, so it is neither synced with nor counted a member, and with the policy every node refuses the changes it commits from then on, judged as the ban stood when they were made (what it wrote before stays), and the tokens it issued. Deleting the entry lifts the ban. A ban doesn't remove the node from the member list: forget it too (`DELETE /peers/`). ```sh curl -X PUT localhost:3000/store/_continuum/banned/ \ -H 'Content-Type: application/json' -d '{"by": "alice", "reason": "compromised"}' ``` ## Values signed by their users A member's node signs its changes, but the user a change names is its node's word: a member could write as any user. A swarm can have its users sign the values that matter themselves, with their SSH keys, so no member can write them but as a user signed them: the policy's `"values": "signed"` (strict; it requires signatures too). ```sh curl -X PUT localhost:3000/store/_continuum/access/policy \ -H 'Content-Type: application/json' -d '{"version": 1, "values": "signed"}' ``` Signed then are what decides who may do what (every user's entry, every entry of `_continuum/access`, every ban), and the paths listed at `_continuum/access/userSigned`, a list of paths as capabilities name them, `*` for any one key: `[["config"], ["servers", "*", "secret"]]`. Each such value is an envelope, one string (`cv1..`): the payload names the path, the value (`null`: deleted), the user, when, and the envelope it replaces (`prev`), and the signature is the user's over it, as `ssh-keygen -Y sign -n continuum-value` makes it, with one of its `keys`. `scripts/continuum-signed` is a reference client, with `ssh-keygen`, `curl` and `jq`: it logs in by key, writes and deletes signed values (`put config '{"mode": "fast"}'`), and checks one against its user's keys (`verify config`). What a client reads to check a signature, the signer's keys, is in the registry, and whom to seal for (Values sealed for their readers, below) in `_continuum/access`: both protocol data, which a user's capabilities cover only if given so. Neither holds secrets (every member holds the document), so give the users who check or seal reading them: ```sh curl -u alice -X POST -H 'Content-Type: application/json' \ --data '{"path": ["_continuum", "users"], "rights": ["read"]}' \ http://localhost:3000/admin/users/bob/caps curl -u alice -X POST -H 'Content-Type: application/json' \ --data '{"path": ["_continuum", "access"], "rights": ["read"]}' \ http://localhost:3000/admin/users/bob/caps ``` Under a strict policy, the same, as part of bob's entry, signed (`continuum-signed put _continuum/users/bob '{…}'`). A node takes one only from that user, for that path, replacing what is there, by one of the user's keys, and if the user may write there ([`api.md`](api.md), Signed values); it writes nothing else there, nor within or above such a value (`409`), and the users API changes nothing (`409`). Values there before the policy stay as they are until replaced: a registry whose entries hold the users' keys turns strict as it is. As it is means trusted as it is: whatever entries, policy and bans it holds then count, plain values and envelopes alike, checked or not, whoever wrote them before (under a policy that isn't strict, a member may write as any user). So a swarm is best strict from its start (`--bootstrap-keys`, A new swarm, above); one already running turns strict through a remake (Turning a swarm strict, below), which keeps only what an operator looked over. Writing the strict policy into it directly trusts it as it is: look it over first (`continuum-signed verify` on each entry). A value there that isn't an envelope starts its chain anew: any envelope its user once signed for that path replacing nothing (`prev` empty) can be put there again. Without the policy, signed values may be written all the same, and plain ones too. Keep two users who may manage users, or two keys each: under a strict policy, a registry whose only manager lost its keys changes no more (the protocol writers do everything there again only while nobody may manage users). Every node judges the changes it receives alike (Changes judged on every node, above), as their writer saw the registry: at such a place, only a string that is an envelope that checks, replacing the one there then; nothing within one; and above one, nothing replacing or deleting what holds one (a map made where there is none yet is fine). So a member writing to its document directly gets nothing there past the others: the registry, the policy and the bans change only as a user who may change them signed. A remake is outside this: its snapshot is the quorum's, so a `replace` or `import` remake a majority of members commits writes at signed places whatever its content holds, plain values too. ### Turning a swarm strict A running swarm turns strict through a remake of its own kind, `strict`, once (a swarm strict already takes none: its registry, policy and bans change by their users' signatures, not by a majority of members; its nodes neither vote for one nor follow one a majority commits anyway, switching or rejoining): compaction that keeps, at the places values are signed (the registry, `_continuum/access`, the bans, the paths listed in `userSigned`), only the values an operator looked over and signed, and sets the strict policy. What was written after its base reaches the new epoch only by replays, which every node judges by the strict policy: a value planted before, plain or signed by a key its planter put there itself, is gone unless the operator kept it. Every node is given the keys whose signature it takes on such a remake, out of the document (`--strict-keys`, `authorized_keys` lines): one an operator holds, the same on a majority of the members at least. A node votes for a strict remake only with its manifest signed by one of them; without any, for none (logged, `Not voting for a strict remake`). Once a majority commits one, every node switches, as with any remake. One that gets no majority aborts, its reason naming the members that didn't vote: those whose `--strict-keys` don't take the manifest's signer, or that were away. ```sh continuum-signed strict ``` shows the values it would keep (`GET /admin/strict-manifest`: every value at a signed place now, by its digest, and the policy with `"values": "signed"`, for this epoch at the heads it is at, so it serves one remake, at those heads, and no other), signs the manifest once confirmed (`ssh-keygen -Y sign -n continuum-strict`, with a key of `--strict-keys`), and proposes the remake at the heads it was made at (`{"kind": "strict", "spec": {"manifest": "\n"}, "baseHeads": […]}`). To keep less, take the manifest from `GET /admin/strict-manifest`, leave out of its `keep` what should go, and sign that instead. Reading it needs reading `_continuum`, proposing writing `_continuum/remakes`. ## Values sealed for their readers Anyone may read the whole document: every member holds it. A value only some may read is sealed for them: encrypted with age to their SSH keys (`keys`, The registry, above), so only they open it. Every node syncs, stores and remakes it as the string it is, and none decrypts it: `cs1.` (binary, not armored). ```sh jq -r '.keys[]' <(curl -s -u alice localhost:3000/store/_continuum/users/bob) > readers printf %s "$secret" | age -R readers | basenc --base64url -w0 | tr -d = | sed 's/^/cs1./' | jq -R . | curl -u alice -X PUT localhost:3000/store/secrets/db -d @- ``` Any value anywhere may be sealed, for anyone. Where the swarm declares it, every value must be: `_continuum/access/sealed`, a list of `{"path": [...], "readers": [...]}`, `*` in a path for any one key, written as any of `_continuum/access` (under a strict policy, signed). ```json [{"path": ["secrets", "*"], "readers": ["alice", "bob"]}, {"path": ["notes"]}] ``` Without `readers`, a value there must be sealed, for anyone; with them, sealed for exactly their keys, as the registry has them when it is written. A node tells without decrypting: an age file names each `ssh-ed25519` recipient by a tag, the first four bytes of the SHA-256 of its key. A write leaving a value there plain, or sealed for a reader's key too few or a stranger's too many, is refused (`409`), and with the policy every node refuses such a change on receipt (Changes judged on every node, above). A value signed by its user (Values signed by their users) holds the sealed value; the signature is over the ciphertext, so every node checks who wrote it without reading it. `scripts/continuum-signed` seals and opens them too, with age: `seal secrets/db '{"password": "…"}'` seals for the readers declared there (or `CONTINUUM_READERS`), signed, and `open secrets/db` opens it with the user's key. It guards against mistakes, not against a writer: one who means to leak a value has it. A reader removed keeps what it read: values stay sealed for the readers they were written for until written again, sealed for those declared then. Which values there are, their sizes, when and by whom they were written stay visible to every member.