@loess/circle
Creates, opens, and reads a circle: an owner creates a circle and commits each new circle record with this package, and a device or a committer opens a circle's head and reads its pages with it. It imports no device code and no committer, so the sdk, the committer, and the host Worker share it.
import { openCircle } from '@loess/circle';
// Reads the head, checks the circle records back to the first, and opens the member's key box.
const opened = yield * openCircle({ circle, member, storage });Architecture shows how an owner changes a circle record, and protocol holds the records this package reads and writes.
Interface
type MemberKeys = { member: MemberId; sign: Signer; box: HeldSealer };A member acts in a circle with its MemberKeys. For an identity, sign is the identity's write key and box its box key, which every device of the identity holds. For a key member, such as a server, they are its own keys. member opens the circle's key box.
| Export | What it does |
|---|---|
createCircle | makes head 1: a first record that its own key signs, listing the owner as an owner and a committer, a commit that accepts nothing, no pages, a key box for everyone, and an owner box |
commitRecord | an owner's commit of a new circle record, outside the inbox, with keys set to same, data, or owners |
openCircle | reads a circle's head, checks its records, and opens the member's key box, owner box, and member list. It fails with CircleUnreadable when it can't |
readIdentityKeys | reads the heads of a list of identities, in two storage reads for any number of them and a third when a record changed, and gives each one's write and box keys only if its head checks against its records back to the first, so storage can't swap in keys of its own |
storedPages | the PageSource of one head, read from storage and opened, with the page map's root and leaves |
commitRecord's keys says what changes. same keeps the key version, and only a member or committer the record adds gets a key box. data makes the next key version, as after removing a member. owners also makes new commit and own keys, as after removing an owner or a committer. A new version seals a key box for every member and committer that stays, and an owner box for every owner. A member that is also a committer has one key box id, so it gets one box that holds the commit key too.
The owner signs the record, and committer, a committer that the new record lists, signs the head. It is the owner when you leave it out. A passkey prompts for each signature, so when a passkey adds a device, passing the device as committer saves a prompt.
Keys in detail
A key box holds the circle key and the box key for everyone; the write key for a writer, an owner, or a committer; and the commit key for an owner or a committer. It is sealed to the box key of its member or committer. Each box also holds every earlier version's circle key, box key, and public write key, so a new member or a new device opens older pages and older boxes from one box, and a committer checks a transaction that waited through a key change.
The own key is in an owner box of its own, and only an owner can make one. An owner box's id comes from the owner's box key and the circle's box key at that record's key version, so an owner works out the id only after it opens its key box, and reads the owner box one round trip later.
An identity's first record lists only the phrase, and identitySecretsOf from protocol derives every byte of it from the phrase, the seal's nonce included, so the phrase alone gives the id. Its head 1 holds no pages. Sign-up then commits a second record with owners, which adds the passkey and the device with new commit and own keys.
To remove a lost phone, the design has the owner's device make the identity's next keys first, which the phone never gets. It then commits a record with new commit and own keys for each circle the identity owns, with the boxes sealed to the identity's next box key, because the phone still opens a box sealed to the current one. Last, it commits the identity's record without the phone. This and a committer that makes a new key version when a member's identity removes a device are not built yet.
Tests
pnpm vitest run --project circle creates circles over memory storage: storage accepts each head 1, and readIdentityKeys gives an identity's keys only from a head its records check, in two reads, or three when a record changed. The committer's tests open the circles they commit with openCircle and read their pages with storedPages.