Part 14 — linking a device, and approving one
Part 5 said a second device shows up with a key gap and receives the key on the next sync. That is the mechanism. This part covers the ceremony around it: who decides that the device in the gap is yours, and what your app displays while that decision is pending.
The question matters because the relay publishes the roster. It has never held a graph key and cannot mint one, but it can add a row: a device ID it made up, with a wrapping key whose private half it holds. Every honest device then sees a member with a gap and, being helpful, fills it. The graph key leaves the fleet without a single byte of ciphertext being broken.
More importantly, a planted device can wait. It does not need an immediate
grant; it can remain in the roster until the next rotate(). Rotation is the
response to a stolen laptop and therefore the worst possible moment to hand
out keys.
Every wrap to another device therefore passes through the same gate, whether creating a graph, filling a gap, or rotating.
Three ways a device gets approved
Section titled “Three ways a device gets approved”Itself, on first run. A device pins its own keys so that creating a graph can wrap to it. Trust on first use; there is no in-band fix for that.
Possession. If this device has opened a record that device sealed under a
graph key and verified its signature under the signing key the roster
publishes for it, that device demonstrably held the key. The relay cannot fake
this: the signature lives inside ciphertext it cannot produce. The device’s
selfSig — a self-signature over its own fingerprint — closes the other half
by binding the published wrapping key to the same device. When both checks
pass, the device is pinned without a prompt.
This is what makes the gate practical. Without it, every already-linked device, every invited member and every recovered laptop would raise a prompt on upgrade day. A product that asks a hundred questions on Monday teaches users to ignore its prompts. With proof of possession, a prompt means something arrived that nothing else could vouch for.
A human. Everything else. The joining device shows its own code; an approver shows what it computed from what the relay published; a person compares two strings.
The joining side
Section titled “The joining side”import { formatPairingCode } from '@argala/client';
const engine = await ArgalaEngine.open({ ...opts, deviceLabel: "Sam's iPad" });const code = await engine.myPairingCode(); // "k4h29tbex0mr"show(formatPairingCode(code)); // "k4h2 · 9tbe · x0mr"The engine returns the raw twelve characters because two devices comparing
strings should compare the same string. formatPairingCode is for the screen.
myPairingCode() touches nothing the relay said — it is derived from this
device’s own keys. That is the whole point: the approver derives the same
twelve characters from what the relay published, and a relay in the middle
cannot make the two agree without a preimage search against a PBKDF2 stretch.
Show it next to status.graphs['g:acme'].key === 'waiting' so the UI can say
“sealed, not empty”.
The approving side
Section titled “The approving side”engine.on('device.pending', async (e) => { // e.deviceId, e.memberId, e.fingerprint const pending = await engine.pendingDevices('g:acme'); showApprovalSheet(pending); // each: { deviceId, memberId, label, fingerprint, code, conflicted }});
// the human tapped yes, having compared the codeawait engine.approveDevice(deviceId, fingerprint);await engine.syncGraph('g:acme'); // the wrap goes out on the next syncapproveDevice takes the fingerprint rather than looking it up, so that what
gets approved is what the human was shown — not whatever the relay is serving
by the time the tap lands.
Codes are derived only in pendingDevices() and myPairingCode(), never on a
sync path. The PBKDF2 stretch is an appropriate cost when a person is about to
read twelve characters from a screen, but not a cost to pay for every device
on every sync.
Events, and what to draw
Section titled “Events, and what to draw”| event / state | means | show |
|---|---|---|
device.pending |
a device nothing can vouch for is waiting | an approval prompt, once per device per boot |
device.conflict |
keys published for an approved device changed | an incident — this has no benign explanation |
status.linking.mode |
'observe' or 'approve' |
a setup warning while observe |
status.linking.pending |
how many are still unanswered | a badge; approve or reject clears it |
error scope linking.unenforced |
keys were handed out unapproved, because mode is observe |
log it; it fires once per boot |
A conflicted pin blocks that device forever. That is deliberate — the relay
uses set-once device IDs, so there is no benign way for a device’s
published keys to change. It is also why forgetDevice() exists: there has to
be a path from any state back to “ask me again”, or a relay that rewrote one
row could brick an honest laptop with no way out.
engine.trustedDevices(); // [{ deviceId, via: 'human' | 'possession' | 'self', state, at, ... }]await engine.rejectDevice(deviceId); // terminal and silentawait engine.forgetDevice(deviceId); // evaluate it againShow via on a “devices you trust” screen. A person should be able to see
which of these a human actually looked at and which were inferred.
Turning it on
Section titled “Turning it on”await ArgalaEngine.open({ ...opts, linking: 'approve' });The default is observe, which reports and hands the key over anyway. That
is the weaker setting and it is deliberate, for one release: an older relay
drops the excluding field on a key put and then refuses the rotation as
incomplete. Flipping the default before relays upgrade would break revocation,
which is worse than the hole it closes. status.linking and the once-per-boot
linking.unenforced error exist so a deployment cannot quietly forget it is
running unenforced.
Under approve, a device nobody approved is simply left out — named in
excluding on the key put, so the relay accepts the rotation as complete
rather than deadlocking on it. The gate never throws. A device nobody has
approved must not be able to stop a rotation, or the gate becomes a way to
disable the only remedy argala has for a compromised device.
What this does not fix
Section titled “What this does not fix”- A hurried human. Twelve characters compared by somebody who wants to get on with their day provide a meaningful but imperfect defence. Put the label and the member next to the code, and make rejecting easy.
- The first device. It pins itself. Trust on first use is trust on first use.
- Devices already linked before you turned this on. They are pinned by possession as soon as they write anything you can open — which is the point, but it does mean a device already inside the fleet was never asked about.
- A read-only member’s device. Possession-pinning needs it to have written something. A device that only ever reads will always need a human.
popPins: falseswitches possession-pinning off, which means approving every device by hand, including every one already linked. It exists for deployments that want that. Most do not.