Skip to content

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.

yes, fingerprint matchesyes, fingerprint changednoyesnoapprovereject

a device appears in the roster

pinned here already?

wrap the key to it

pin: conflicted
device.conflict
never wrapped again

has it written a record
this device opened and verified,
and does its selfSig check out?

pin: approved, via 'possession'

device.pending
show the code

a human compares
twelve characters

pin: approved, via 'human'

pin: rejected — silent, no repeat prompt

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.

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

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 code
await engine.approveDevice(deviceId, fingerprint);
await engine.syncGraph('g:acme'); // the wrap goes out on the next sync

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

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 silent
await engine.forgetDevice(deviceId); // evaluate it again

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

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.

  • 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: false switches 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.