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).
The registry
_continuum/users/<name>, 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).
{ "since": 1762400000000, "by": "continuum:3f…",
"caps": { "8c41e2…": { "path": [], "rights": ["read", "write"],
"by": "continuum:3f…", "since": 1762400000000 } },
"disabled": false }
- Names are what
--htpasswdand the proxy give: 1 to 64 ofA-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/eucoversservers/eu/0, notservers/europe),*for any one key (servers/*/namecoversservers/eu/nameandservers/us/name; a key that is*itself can't be named alone). One outside protocol data never covers protocol data, nor one with*first:[]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 asuser:<name>, 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 butfalsedisables.keys: the user's SSH keys, each by a name of its choosing, as anauthorized_keysline has it ("laptop": "ssh-ed25519 AAAA… alice@laptop"; Ed25519 only; the comment, 256 bytes at most, is kept and checks nothing): 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/<name>with the line,DELETE /auth/keys/<name>(not with a token that has alimit); 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. Its last key it removes only on a node where it has a password (--htpasswd), which it logs in by then (409elsewhere); under a strict policy, never (it signs its entry with one).
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) takeswriteon 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=truedeletes maps left empty only up to the highest one the user may write. Reading takesread: 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 onservers/eushowsserverswitheualone in it), and nothing else; a list on the way is left out whole, as its items have no paths of their own. A shallow read (?shallow=true) shows such a map as a map and no more (no size), and nothing beside the way: a scalar at a*no capability covers stays hidden. Native forms (text/plainof 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:readandwriteon 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):readon_continuum/remakesto look,writeto propose or abort one. Forgetting a peer (DELETE /peers/…):writeon_continuum/forgotten. - What a node holds alone (its log filter,
/admin/log; its configuration,/admin/config;/debug):readto look andwriteto act on_continuum/local/<node ID>/log,…/config,…/debug(/debugalwayswrite). Nothing is stored there, and nothing may be written there: the paths only name these for capabilities._continuum/local/<node ID>is everything on one node,_continuum/local/*/logthe log filter on every node. - Everything else an operator does (
/admin/document,/admin/changes,/admin/epochs,/admin/quarantine):readon_continuumto look,writeon 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 <token> instead of a password:
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 asPOST /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). - Revoking: a user disabled or removed is refused at once; all of a user's tokens,
by the user (
POST /auth/logout, not with a token that has alimit) or by one who may manage users (DELETE /admin/users/{name}/tokens), which sets_continuum/logouts/<name>: tokens issued before aren't taken any more. One session's (DELETE /auth/session, the dashboard's "log out", with any token of the session, a narrowed one too, as a dashboard opened from another member's holds):_continuum/sessions/<name>/<session>, until when the session would have lasted, written by the user or one who manages users; a remake's snapshot leaves it out once that has passed. Without a registry, the node ending it refuses the session's tokens itself, until it restarts; others take them until they expire. A stream opened with a token (server-sent events, the store's streams) ends with it: when it expires, or once the node holds a change that revokes it (looked at as protocol data changes), a session's end on the node itself included. One opened by password or a proxy's header ends once its user is disabled or removed. 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). 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").
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).
GET /auth/whoami says the swarm, who a request's user is, how it proved it, its
capabilities (caps), and what this request may do (may): those, or with a token
that says less (limit), what both give, each capability's path the deeper of the two
and its rights those both name.
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, and one written out,
/admin/dashboard/messages/own/{seq}), its figures and peers (/admin/stats,
/peers), /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, 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).
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:
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.
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/<id>) 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)._continuum/localby none. - 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 atservers/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, Signed changes): every node then signs what it sends, so every
change leaves vouched for, and one no signature vouches for yet waits, with whatever builds on it, until one does
(they come with the changes, in the same sync messages). 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, its document swapped meanwhile or not (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. Its seeds are the nodes proven at the seeds' addresses: another peer naming
a root (one of a member's own identities, say) disputes it, but neither ends the wait
nor starts it. So without --genesis-root, the root is the one the seeds that answer
within that wait agree on, none while any peer names another: one seed hands a joiner
another root only if it is all the joiner hears of its seeds 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/<node ID>, 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/<node ID>).
curl -X PUT localhost:3000/store/_continuum/banned/<node ID> \
-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).
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.<payload>.<signature>): 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:
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, 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). Nodes tell such a remake by its
kind, which its proposal and its snapshot's metadata say: a majority of members
labelling it otherwise (compact, its snapshot holding the registry it wants) gets it
followed, as nodes whose own build differs take the quorum's snapshot. So a strict
swarm's registry, policy and bans change by its users' signatures against a minority of
members, not against a majority (trust.md). 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.
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": "<its line>\n<its signature>"}, "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.<the age file, base64url> (binary, not armored).
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).
[{"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.
