Skip to content

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.

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.
u the caller's personal graph
g:<groupId> a shared graph; membership answered by the Directory
d:<memberId> the pairwise graph between the caller and that member

The 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.

  • 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.stringify preserves 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_-].

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:

  1. the ciphertext opens under the AAD naming its slot;
  2. the envelope parses, and r.id matches the slot it arrived in;
  3. the app’s schema for r.kind accepts r.data (tombstones excepted);
  4. unless the app opted out, d names a device in the graph’s roster, s verifies over the signing bytes of r as it was sealed (see Cryptography — not of r after unknown members were dropped), r.by equals that device’s member, and the device was not already revoked when r.at says 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.

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

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.

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.

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.

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 = ciphertext
GraphOpOut = GraphOpIn & {seq}

Push semantics, per op:

  1. If rev is more than maxRevSkewMs ahead of the relay’s clock, reject it with rev-too-far-ahead and continue. A device with a broken clock would otherwise pin a record nothing could ever supersede.
  2. If a row exists with rev >= op.rev, skip it silently. Losing is normal; it is how devices converge.
  3. If a quota would be exceeded, reject that op with a reason and continue.
  4. Otherwise upsert, assigning the next seq from 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.

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:

  1. every entry names a record it holds at that rev or newer, unless it refused that record deliberately;
  2. 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.

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.

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 exactly latest + 1 (409 epoch-gap otherwise) and must cover every eligible device in one request (400 incomplete-rotation with the missing IDs otherwise). A rotation that strands somebody is refused, not accepted with a warning — unless it says so. excluding names 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 the graph.rotate event: 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 header
POST /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.

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.

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.