Protocol
Everything a second implementation would need. The protocol is deliberately small: HTTP with JSON bodies, one binary endpoint for blobs, no streaming requirement, no server-side state machine. A relay is a database with fifteen routes and no curiosity.
Types are normative as written in @argala/core (protocol.ts, keys.ts);
this file explains what they mean and why they are shaped that way.
Vocabulary
Section titled “Vocabulary”| Term | Meaning |
|---|---|
| member | An identity in the host app. argala never defines one. |
| device | One machine holding keys. A member has many; each has its own wrapping and signing keypairs. |
| graph | A namespace of records under one keyring. |
| record | {id, kind, deleted?, data, at, by}, encrypted as one unit. |
| epoch | A generation of a graph’s key. Rotation mints the next one. |
| rev | A record’s revision — the device’s monotonic timestamp. LWW compares these. |
| seq | The relay’s per-graph write counter. Pull cursors are seqs. |
Graph IDs
Section titled “Graph IDs”u the caller's personal graphg:<groupId> a shared graph; membership answered by the Directoryd:<memberId> the pairwise graph between the caller and that memberThe ID portion must match [A-Za-z0-9_-]{1,64}. A relay maps these to
storage keys,
which clients never send and never see:
u -> u:<callerId>g:<id> -> g:<id>d:<other> -> d:<a>:<b> where [a, b] = sort([callerId, other])The pairwise form is order-independent on purpose: Sam addresses the graph as
d:alex and Alex addresses the same graph as d:sam, and both must land on
one row. Everything that binds or signs uses the storage key, so both ends
compute the same string with nothing coordinating them.
Cryptography
Section titled “Cryptography”-
Records and blobs: AES-256-GCM. Nonce is 96 random bits, fresh per encryption.
-
AAD:
<storageKey>/<recordId>for records,<storageKey>/blob/<blobId>for blobs. This is the whole anti-swap defense: ciphertext lifted from one slot and replayed into another fails to open, because the slot’s name is authenticated data. A relay that shuffles rows produces errors, not wrong answers. -
Key wrapping: ephemeral ECDH P-256 → HKDF-SHA256 (
info: "skm-wrap-v1") → AES-256-GCM over the 32-byte graph key. The wrap carries its ephemeral public key (epk, a JWK, base64url). -
Signatures: ECDSA P-256 with SHA-256, over
"skm-record-v1\n" + storageKey + "\n" + canonicalJson(record). Canonical means object keys sorted recursively —JSON.stringifypreserves insertion order, which is a property of how an object was built rather than of what it contains, and two devices that build one record differently would otherwise disagree about its bytes.A verifier MUST canonicalise the record as it was sealed, including any member it does not recognise, and MUST NOT re-canonicalise the record after discarding unknown members. Dropping them first yields different bytes from the ones the writer signed, so every record from a writer that knows one more field than the reader fails signature verification — a fleet-wide, one-way partition that presents as forgery rather than as version skew. The unknown member is covered by the signature and then ignored: a verifier applies only the members it understands.
argala does not use Ed25519, although it would otherwise be the better scheme. WebCrypto support arrived late and unevenly, and a signature scheme absent from a member’s device is worse than a larger one that works consistently.
-
Code-derived keys: PBKDF2-SHA256, 310,000 iterations, 128-bit salt. Used for invite keyrings and recovery bundles.
-
Derived tags: HKDF(key, label) → HMAC-SHA256(input), truncated to 132 bits, for unlinkable personal record IDs.
All base64url values are unpadded and match [A-Za-z0-9_-].
Records and the envelope
Section titled “Records and the envelope”interface GraphRecord { id: string; // [A-Za-z0-9_-]{1,64} kind: string; // the app's schema name deleted?: true; // tombstone: an empty husk that keeps syncing data: Record<string, unknown>; at: number; // the revision; monotonic per device by: string; // author's member id}
interface RecordEnvelope { // what is actually sealed v: 1; r: GraphRecord; d?: string; // the DEVICE that signed s?: string; // base64url ECDSA signature}The signature lives inside the ciphertext rather than beside it on the wire. The relay cannot verify it (no plaintext, no roster trust) and does not need it, so putting it outside would leak an author to the one party that should not learn the authorship.
A receiver accepts a record only if all of the following conditions hold:
- the ciphertext opens under the AAD naming its slot;
- the envelope parses, and
r.idmatches the slot it arrived in; - the app’s schema for
r.kindacceptsr.data(tombstones excepted); - unless the app opted out,
dnames a device in the graph’s roster,sverifies over the signing bytes ofras it was sealed (see Cryptography — not ofrafter unknown members were dropped),r.byequals that device’s member, and the device was not already revoked whenr.atsays the record was written.
A record that fails any of these checks is skipped, and the reason is reported. It is never applied on a “best effort” basis.
at must be monotonic per device AND must exceed the revision it replaces.
The relay drops a push whose rev is <= the one it holds, so a device that
stamps a losing revision keeps content the relay never accepted — a silent
divergence with no error anywhere, made permanent by the fact that the pull
cursor has already passed the relay’s copy. Stamp
max(now, last + 1, priorRev + 1), where priorRev is the at of the record
being overwritten, or 0 for a new one.
The second term handles the obvious case: two writes to one record within a single millisecond. The third is easier to miss and more damaging — editing a record another device wrote from a clock a few milliseconds ahead produces a LOWER revision unless the local copy is read first.
Granularity is the app’s decision, and it is the important one. A chat message is one record because nobody co-edits a sentence already sent. A document should be a header record plus one record per block, so LWW merges at paragraph granularity instead of clobbering an afternoon. argala gives you per-record merge; where you put the record boundaries decides what “conflict” means in your app.
Limits
Section titled “Limits”These values are normative and come from LIMITS in @argala/core. A relay
MAY enforce stricter limits, but never looser ones, because a conforming client
must not be rejected for respecting these limits.
| Limit | Value | Why |
|---|---|---|
maxRecordBytes |
512 KiB | plaintext record; larger content is a blob |
maxCiphertextChars |
700,000 | one sealed record on the wire |
maxOpsPerPush |
200 | records per push |
maxPushBytes |
8 MiB | and a byte budget: 200 maximal records total 140 MB, and the resulting 413 is not retryable, so the queue would stop draining and stay stopped |
maxPullLimit |
500 | records per pull page |
maxBlobBytes |
25 MiB | one blob |
maxRevSkewMs |
5 min | how far ahead of the relay a revision may be stamped |
Routes
Section titled “Routes”All paths are relative to wherever the relay is mounted. Every route requires authentication; the token must name both the member and the device. Every route except device registration additionally requires that the named device is registered to the named member and not revoked.
Devices
Section titled “Devices”PUT /devices/:deviceId {pubJwk, sigJwk, selfSig?, label?} -> {ok, deviceId, fresh}GET /devices -> {devices: [...]}POST /devices/:deviceId/revoke -> {ok, rotationOwedFor}PUT is set-once and takes both keys at once: a device that could add a
signing key later could sign yesterday’s history with today’s key. A device ID
already bound to different keys is refused with 409 device-key-conflict —
rebinding is exactly how an attacker would redirect the next grant. The honest
response to a 409 is a new device ID, never a rebind.
selfSig is the device’s own signature, under its signing key, over
selfAttestationBytes(fingerprint) — the bytes
skm-device-self-v1\n<fingerprint>. It binds the WRAPPING key the relay
publishes for a device to the SIGNING key that device’s records verify under,
so a relay cannot publish an honest signing key beside a wrapping key it holds
the private half of. The relay stores and republishes it verbatim and cannot
check it; a device that verifies it has proven nothing about who the device
belongs to, only that both published keys came from one holder. It is optional
on the wire because devices registered before it existed do not have one, and
a device without one is simply never auto-approved.
The device fingerprint is
SHA-256("skm-device-v1\n<memberId>\n<thumbprint(pubJwk)>\n<thumbprint(sigJwk)>"),
base64url, where each thumbprint is RFC 7638 over the JWK’s required members
only (crv, kty, x, y for EC). The device ID is deliberately NOT in the
digest: it is a free-form string a relay chooses, and including it would turn
a preimage search into a cheap grind over one field.
The pairing code a human compares is 12 characters of Crockford base32
(lowercase, no i, l, o or u) over
PBKDF2-SHA256(fingerprint, "skm-pair-v1", 310000) taken to 64 bits. The
stretch raises the cost of every guess, allowing the code to use 12 characters
instead of 40.
The code is deliberately one-sided — derived from the joining device alone, not from both parties the way a Signal safety number is. A symmetric construction would let a relay controlling both screens search two candidate sets at once and look for a collision between them; that is a birthday attack, and resisting it would cost twice the characters.
Both derivations are client-side. They are specified here because two implementations must agree on them exactly, or the ceremony has a human comparing two different strings and concluding something is wrong.
Revoke drops the device’s wraps, marks it revoked, and sets
rotation_required on every graph it held keys for. It does not, and cannot,
reach into a device that already synced.
Capabilities
Section titled “Capabilities”A relay MAY refuse an operation a member is not permitted to perform, with
403 {error: 'not-permitted', need: <capability>}. Two capabilities are
defined, and both name an operation the relay MEDIATES:
| capability | gates |
|---|---|
write |
POST push, POST blobs/:id, DELETE blobs/:id |
rotate |
POST keys where epoch > latest — minting a new key epoch |
Where the permissions come from is outside this protocol: they are the host app’s, arriving through the same seam as membership. A relay that has no opinion MUST behave as though every member holds both, so that a deployment which has not configured any keeps working.
A capability is not a confidentiality boundary. A member holding a graph key can decrypt every record in it; refusing their writes does not and cannot change that. To stop somebody reading, remove them and rotate. Nor is this a defence against a hostile relay, which decides the answer it then enforces.
A client MUST NOT treat 403 not-permitted as a transport failure. It is not
retryable, and it must not abort a sync: a device that is permitted to read
and not to write has to keep pulling, so the correct handling is to hold the
queued writes, report the refusal, and continue with the pull.
Records
Section titled “Records”POST /graphs/:gid/push {ops: GraphOpIn[]} -> {accepted, cursor, now, epoch, rejected?}GET /graphs/:gid/pull?after=&limit=&kinds=&shards= -> {ops: GraphOpOut[], cursor, more, epoch}GET /graphs/:gid/log?after=&limit= -> {entries: LogEntry[], cursor, more, head, oldest}GraphOpIn = {recordId, rev, e?, n, c, k?, sh?} // e = epoch, n = nonce, c = ciphertextGraphOpOut = GraphOpIn & {seq}Push semantics, per op:
- If
revis more thanmaxRevSkewMsahead of the relay’s clock, reject it withrev-too-far-aheadand continue. A device with a broken clock would otherwise pin a record nothing could ever supersede. - If a row exists with
rev >= op.rev, skip it silently. Losing is normal; it is how devices converge. - If a quota would be exceeded, reject that op with a reason and continue.
- Otherwise upsert, assigning the next
seqfrom a per-graph counter.
A push is not all-or-nothing: rejected names each refused record and why, so
a client is told precisely what did not land instead of inferring it from a
count. now is the relay’s clock — the reference a device with a wrong clock
uses to correct itself, since retrying from the same wrong clock cannot work.
epoch on both answers is the graph’s current key generation (0 = no key
yet). It is how a device learns that somebody rotated: without it, a device
only finds out by meeting a record it cannot open, and until then it keeps
sealing under the key the rotation was performed to take away. A client
SHOULD treat an epoch above the one it holds as a signal to fetch keys
before its next write. It is optional on the wire so a client can talk to an
older relay, and a client MUST only ever move its idea of the epoch upward.
The sequence comes from a counter, never from MAX(seq) + 1: a number reused
after a delete would make a puller skip a record.
Pull returns ops with seq > after, ascending, at most limit. more says
another page waits. Clients persist cursor per subscription, because a
filtered and an unfiltered view of one graph are at different places in the
same sequence.
A client that encounters an op sealed under an epoch it lacks must stop before that op without advancing its cursor, ask for keys, and resume later. Skipping it loses the record forever.
The append-only log
Section titled “The append-only log”LogEntry = { seq, recordId, rev };One line per accepted write, in the order the relay assigned. Nothing here is
a field the relay did not already have: seq it assigned, recordId and
rev it was handed on the push. It is not filtered and MUST NOT be — a client
that asked to see a subset cannot verify a chain over everything.
Why it exists. graph_records is keyed by record ID, so an update moves
the row to a new sequence number and the version it replaced ceases to exist in
that table. A client that joined last week and one that joined today therefore
pull different sequences of rows, and no chain computed over those rows could
ever agree. The log does not move, so a chain over the log does.
A relay MUST append inside the same transaction as the record upsert, MUST NOT
log a write it refused (a last-write-wins loser is not a write), and MUST NOT
rewrite an entry. head is the highest seq in the log; oldest is the lowest,
so a client whose cursor predates the log knows that the span cannot be checked
rather than concluding that the relay omitted it.
The chain, over entries in seq order, is what clients compare:
h₀ = base64url(SHA-256("skm-log-v1\ngenesis\n" + storageKey))hₖ = base64url(SHA-256("skm-log-v1\n" + hₖ₋₁ + "\n" + seq + "\n" + recordId + "\n" + rev))Genesis is bound to the storage key so one graph’s log cannot pass as another’s. Sequence numbers are NOT contiguous — a push allocates its whole range up front and a refused op spends one — so a chain over “every entry present, in order” is the definition, not “every seq from 1”.
A client SHOULD verify two things after a pull, and only for an unfiltered subscription, and only up to the sequence number it was actually served:
- every entry names a record it holds at that
revor newer, unless it refused that record deliberately; - its chain head at a checkpoint boundary equals what other devices signed for the same boundary.
Failing (1) means the relay contradicted its own account of a push it accepted. Failing (2) means it is not serving one graph to everybody.
Checkpoints
Section titled “Checkpoints”A device publishes its chain head as an ordinary record — sealed, signed,
pushed, and verified against the roster like anything else — of reserved kind
skm.checkpoint:
data = { v: 1, d: deviceId, seq, head };d is not decoration. The record ID below is derived from the graph key, so
every member can compute every other device’s slot and write into it. A
receiver MUST believe a checkpoint only when its ID is the one derived for
d, and when the record’s proven author (by, checked against the signature
and the roster) owns device d. Without both checks a member could put a
false history in an honest device’s mouth, or inflate how many devices appear
to corroborate one.
seq MUST be a multiple of 64, so that two devices crossing the log at
different moments hold a head at the same point rather than two nearby ones.
The head at boundary B is the chain after folding every entry with seq ≤ B,
which is defined whether or not an entry sits exactly on B.
The record ID MUST NOT be derived from anything the relay can link to a
device. argala uses HMAC(HKDF(epoch-1 key, "skm-ckpt-v1"), deviceId), for the
same reason personal record IDs are derived: IDs are visible, and naming this
one after its author would hand back the authorship that keeping signatures
inside the ciphertext exists to hide.
Kinds beginning argala. are reserved for the engine and MUST be validated
against the protocol’s own schemas, never against an application’s registry.
Partial sync
Section titled “Partial sync”k (kind) and sh (shard) on a pushed op are stored and readable by the
relay forever. That is the entire point of them and the entire cost. A
client sends them only when the app has decided partial sync is worth the
leak; the default in @argala/client is to send neither.
shard is an app-chosen label — a channel, a month, a project — which SHOULD
be an opaque derived tag rather than a name, and MUST NOT change over a
record’s life: the relay filters on it, so a record that moves shards would
vanish from one subscription without appearing in the other.
A filtered pull that exhausts its matches advances cursor to the
graph’s high-water mark rather than leaving it where it was, so the next pull
does not rescan the same range forever. Anything written after that mark
necessarily has a higher seq, so nothing can be skipped by it.
GET /graphs/:gid/keys -> {latestEpoch, rotationRequired, wraps: [{epoch, wrapped}]}GET /graphs/:gid/devices -> {devices: [{deviceId, memberId, pubJwk, sigJwk, selfSig, revokedAt}]}GET /graphs/:gid/keygaps -> {latestEpoch, gaps: [{deviceId, memberId, pubJwk, sigJwk, selfSig, epochs}]}POST /graphs/:gid/keys {epoch, wraps: [{deviceId, wrapped}], excluding?} -> {ok, latestEpoch}DELETE /graphs/:gid/keys/:epoch -> {ok, discarded}GET keys returns wraps for the calling device only.
GET devices is the roster: what a new epoch must cover, and what signatures
verify against. It includes revoked devices, marked with revokedAt — a
signature made before a revocation is still a true statement about who wrote
that record, and dropping the key would make every record a retired laptop
ever wrote unverifiable. Wrapping filters to revokedAt === null;
verification does not.
It also includes devices of members who have since left the graph, for the same reason one level up. A roster built from current membership alone answers a different question depending on when it is asked, so offboarding somebody would make every record they ever wrote unattributable — kept by devices that had already synced, dropped by every device that synced afterwards. A relay MUST therefore serve the devices of every member who has pushed to the graph, not only those who are in it now. This grants a departed member nothing: they cannot push, and the completeness check for a new epoch still covers current members only, so the next key is not wrapped to them.
keygaps is the work list: devices holding no wrap for some epoch, with the
public keys to wrap to — pulled from the roster, never supplied by the asking
device, so a forged request cannot redirect a key. It carries sigJwk and
selfSig alongside pubJwk because the granting device has to fingerprint
the whole triple before it decides to wrap; handing it only the wrapping key
would mean approving one thing and encrypting to another.
POST keys has two modes:
epoch > latest— init or rotation. Must be exactlylatest + 1(409 epoch-gapotherwise) and must cover every eligible device in one request (400 incomplete-rotationwith the missing IDs otherwise). A rotation that strands somebody is refused, not accepted with a warning — unless it says so.excludingnames devices the caller is stranding on purpose, so that a device nobody has approved does not deadlock rotation against the completeness rule. A device may not be both wrapped to and excluded, and may not be excluded if it is not eligible in the first place (400 invalid-exclusion). The relay reports the exclusions on thegraph.rotateevent: a field that could strand a device silently would be worse than no field.epoch <= latest— a grant to devices that lack that epoch. Existing wraps are never overwritten, so a key holder cannot replace another device’s copy with one it cannot open.
Minting an epoch (epoch > latest) additionally requires the rotate
capability, and a caller without it is refused 403 not-permitted {need}.
Filling a gap (epoch <= latest) does not: the grant hands over a key its
caller already holds, and a member who could not add a second device would
have an unusable seat rather than a restricted one.
Every wrap must address an active device belonging to a current member;
otherwise 400 not-a-member-device. A key holder could always hand the key
over out of band — what the relay refuses is to be the thing that stores it
for an outsider.
DELETE keys/:epoch discards the calling device’s own wrap. A wrap a
device cannot open is worse than no wrap: the relay believes the key was
delivered, so keygaps reports nothing and nobody re-grants it. That happens
by accident, and on purpose — the relay cannot verify that a wrap decrypts,
because it cannot decrypt — so a device that fails to unwrap discards the wrap
and reappears in the gap list, where somebody holding the key fills it
properly.
POST /graphs/:gid/blobs/:blobId?policy=<p>&ttl=<ms> body: raw ciphertext headers: content-type: application/octet-stream, x-blob-nonce: <b64>GET /graphs/:gid/blobs/:blobId -> raw ciphertext, x-blob-nonce headerPOST /graphs/:gid/blobs/:blobId/ack -> {ok, forgotten}DELETE /graphs/:gid/blobs/:blobId -> {ok}Policies: retain (default), transport (deleted once every current member
has acked), ephemeral (deleted after ttl, max 30 days). Uploading counts
as one receipt. A sweeper re-checks transport blobs against the current
member set, because that set shrinks and a blob waiting on a departed member
would otherwise wait forever.
A blob ID is immutable: re-uploading one that exists is a retry, answered with
duplicate: true, never an edit. Overwriting would let one member swap the
bytes under a record another member already read.
Ceremonies
Section titled “Ceremonies”Bootstrap a graph. GET devices → generate a 256-bit key → wrap to every
active device the creating device will vouch for → POST keys {epoch: 1, wraps, excluding}. On a fleet of one that is the creating device itself,
which pins its own keys: trust on first use, and there is no in-band fix
for it.
Link a device. New device generates both keypairs and a selfSig over its
own fingerprint, PUT /devices/:id, then polls GET keys and enters the
waiting state. Any device holding the keyring reads GET keygaps and, for each
gap, fingerprints the published key triple and decides whether it will wrap to
it at all: automatically if that device has already written a record this one
opened and verified and its selfSig checks out, otherwise only after a human
compares pairing codes. It then wraps each missing epoch and POST keys,
naming in excluding any device it refused. No private key ever moves.
This is the ceremony that makes a recovery bundle a recovery mechanism rather
than an onboarding step, and the approval step is what stops the relay from
adding a device of its own and waiting to be granted.
Invite a member with the keys. Mint keySecret and a salt; seal the
keyring under PBKDF2(keySecret, salt); store the sealed blob with the
invite. The invite string is <doorCode>.<keySecret> — only the door code
is ever sent to the server. The invitee splits it, derives the key, opens
the keyring.
Rotate. GET devices → new key → wrap to every active device the rotating
device will vouch for → POST keys {epoch: latest+1, excluding}. A removed
member retains what they already synced but can read nothing written
afterward. Rotation runs the same approval gate as a grant for the same reason:
responding to a stolen laptop is the worst possible moment to hand a key to a
device nobody recognises.
Recover. The bundle is {v: 1, salt, sealed}, sealed under a passphrase,
carrying graph keyrings only — never a device keypair. A restored machine
is a NEW device: it registers its own keys and adopts the graph keys. Copying
the old keypair across would mean two machines signing as one device, which
makes revocation meaningless.
What the relay sees
Section titled “What the relay sees”It cannot avoid seeing: which member syncs which graph and when, record IDs, ciphertext sizes, revisions and sequence numbers, epoch numbers, blob sizes and policies, the device roster with public keys and self-attestations, which devices a rotation deliberately excluded, and — where an app opted in — record kinds and shard labels.
It never sees: record contents, blob contents, graph keys, any private key, record authorship, or which records a member reads. The log adds no new field the relay can read, but it does make the relay RETAIN the shape of a record’s edit history rather than overwrite it. This is a meaningful change in retention and is documented as such in SECURITY.md.
Metadata is a real channel. SECURITY.md is where argala is honest about it,
including a leak it fixed and one it cannot fix retroactively.