Device linking v1
Device linking v1version 1status: implemented, normativerim-server/docs/specs/device-linking-v1.mdcommit 98d1883
Status: implemented, normative. Extends docs/specs/key-identity-v1.md
(read it first: keys, tagged encodings, device certificates and challenge login are defined
there). This document is the contract between the server (DeviceLinkService), the desktop
client and the web client for giving a new device its own certified device key.
Summary #
- The old device (signed in, holds the identity key) calls
startDeviceLinkand shows the coderim-link:1:<linkId>:<secret>:<endpoint>as a QR code and as text. The link lives 10 minutes. - The 32-byte secret never reaches the server. The server makes the 16-byte link id and stores only its SHA-256.
- Channel: HKDF-SHA256(secret, salt = link id) with one
infoper direction, then AES-256-GCM with a random 96-bit nonce and AAD naming version, direction and link id. - The new device (no session) seals an offer (device id, tagged device key, name, platform,
statement version) and sends it with
sendDeviceLinkOffer. One offer per link. - The old device reads it through
deviceLink, shows the name and an 8-character fingerprint the new device also shows, signs a certificate, callsaddDeviceKey, sends a sealed answer (the certificate, nothing else) withsendDeviceLinkAnswer, then republishes its profile card. - The new device polls
deviceLinkAnswer, verifies the certificate againstkeyLog, stores its keys, signs in withkeyLoginChallenge+loginWithKey, and callscompleteDeviceLink. - States:
open→offered→answered→consumed;cancelledand expiry end any live state. - Anonymous calls answer
UNAUTHENTICATEDalike for every link they cannot act on, and are rate-limited per IP. The server stores sealed bytes and never interprets them. - Storage is a
device_linktable behind a repository trait, likekey_challenge. - The answer carries nothing secret in v1, because anyone who saw the code can open it. The archive key and moving the identity key are deferred.
What the channel protects #
The server is a courier. It sees the link id, the sealed bytes and their sizes, and which account started the link. It never sees the secret.
| Adversary | Can | Cannot |
|---|---|---|
| The server, or anyone who learns only the link id | Drop, delay or cancel a link; post a bogus offer (fails to open, link cancelled) | Read the offer or answer; substitute its own device key; turn an offer into an answer |
| Anyone who saw the code (QR photographed, text pasted somewhere) | Everything above; post an offer first; open the answer | Get a certificate unless the person confirms their offer on the old device |
The confirmation step on the old device is the defense against the second row. The channel is symmetric only (AES-256 under a 256-bit secret), so there is no key agreement to record now and break later; the hybrid post-quantum decision asks nothing of it.
Flow #
old device (session) server new device (no session)
startDeviceLink ───────────────────────► open
show code (QR + text) · · · · · · · · · · · · · · · · · · · · · ► paste code
◄─────────── sendDeviceLinkOffer(sealed offer)
deviceLink (poll) ◄──────────────────── offered show fingerprint, poll
open offer, person confirms name + fingerprint
addDeviceKey(certificate) ─────────────► device added
sendDeviceLinkAnswer(sealed answer) ───► answered
publishProfileCard (best effort) ────────────► deviceLinkAnswer (poll)
open, verify against keyLog
◄─────────── keyLoginChallenge, loginWithKey
deviceLink (poll) ◄──────────────────── consumed ◄────────────── completeDeviceLink (session)
Either side may call cancelDeviceLink at any point before consumed.
Link code #
rim-link:1:AAECAwQFBgcICQoLDA0ODw:ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8:http://localhost:8080/api
| Part | Value |
|---|---|
| Scheme | rim-link: (readers compare it ASCII case-insensitively; writers write lowercase) |
| Version | 1, decimal. The version of everything in this document: offer, answer, info, AAD |
| Link id | 16 bytes, unpadded base64url, exactly 22 characters |
| Secret | 32 bytes from the OS random generator, unpadded base64url, exactly 43 characters |
| Endpoint | The old device's GraphQL endpoint as configured, trailing / removed |
Readers:
- Remove every ASCII whitespace character (chat apps and mail wrap long lines): exactly TAB,
LF, FF, CR and SPACE (
0x09,0x0A,0x0C,0x0D,0x20), not VT (0x0B) and nothing outside ASCII. A JavaScript reader must not use/\s/, which matches more. - Refuse anything longer than 334 characters (78 fixed + a 256-byte endpoint).
- Strip the scheme and split the rest at the first three
:into version, link id, secret and endpoint (the endpoint keeps its own colons). - The version is a decimal number above 0 without leading zeros; anything else (
0,01,x) is not a link code. Refuse a higher version than they implement with "this code was made by a newer app; update this device". Decode link id and secret strictly (no padding, canonical trailing bits, exact length). - The endpoint must start with
http://orhttps://, be 1 to 256 bytes of printable ASCII (0x21to0x7E) and contain no?,#or@.
A QR code encodes exactly this string in byte mode, error correction level M. The example above is 103 characters.
Channel cryptography #
Keys #
K_offer = HKDF-SHA256(ikm = secret, salt = link id (16 raw bytes), info = "rim.device-link.v1 offer", L = 32)
K_answer = HKDF-SHA256(ikm = secret, salt = link id (16 raw bytes), info = "rim.device-link.v1 answer", L = 32)
Sealing #
- AES-256-GCM, 96-bit nonce from the OS random generator, 128-bit tag. AES-GCM rather than XChaCha20-Poly1305 (which the desktop uses for the history archive) because WebCrypto has AES-GCM and no ChaCha20, and the web client must seal and open with non-extractable keys.
- AAD is ASCII
rim.device-link.v1\0<direction>\0<link id text>, where<direction>isofferoranswerand<link id text>is the 22-character base64url form. Distinct keys and distinct AAD mean an offer never opens as an answer and a message never moves to another link. - Each side seals exactly one plaintext per link. A retry resends the same sealed bytes. With at most one message per key, a random nonce cannot repeat under a key.
Sealed message #
On the wire, like SignedStatementInput:
{ "nonce": "QEFCQ0RFRkdISUpL", "ciphertext": "J8-HezTgXKj9…" }
nonce is the 12-byte nonce, ciphertext is the AES-GCM output with the 16-byte tag
appended (what WebCrypto encrypt returns), both unpadded base64url.
| Max plaintext | Max ciphertext bytes |
|
|---|---|---|
| Offer | 16 KiB | 16,400 |
| Answer | 64 KiB | 65,552 |
Sized for hybrid keys, as in key-identity: the offer holds a tagged ML-DSA-87 hybrid key (about 3.5 KB) several times over; the answer holds a 16 KiB certificate payload escaped in JSON (at most twice its size) plus a hybrid signature (about 6.3 KB for ML-DSA-87).
Plaintext rules #
The plaintext is UTF-8 JSON with "v" (the code version, 1) and "type". The rules of
key-identity statements apply: unknown fields, duplicate fields and a wrong type are refused
(a browser's JSON.parse cannot see duplicates; that is acceptable there, since the AEAD already
proves the writer knew the secret). A plaintext that fails these rules after it opened is treated
exactly like one that failed to open.
Offer (type link-offer), sealed by the new device:
{"v":1,"type":"link-offer","deviceId":"<uuid>","deviceKey":"ed25519:…","name":"Laptop","platform":"desktop","statementVersion":2}
| Field | Rule |
|---|---|
deviceId |
A UUID, lowercase with hyphens, chosen by the new device (v7 recommended) |
deviceKey |
The new device key in tagged (version 2) form |
name |
The certificate name rules: trimmed, 1 to 64 characters, no control characters |
platform |
desktop or web. Self-reported, so a display hint only |
statementVersion |
The highest key-identity statement version the new device reads; at least 2 |
There is no signature by the device key in the offer. The first loginWithKey proves the new
device holds it, and a certificate for a key nobody holds signs nothing in.
Answer (type link-answer), sealed by the old device:
{"v":1,"type":"link-answer","certificate":{"payload":"{\"v\":2,\"type\":\"device\",…}","signature":"ed25519:…"}}
certificate is the device certificate exactly as sent to addDeviceKey. The old device writes
it in statement version min(its own write version, the offer's statementVersion): 2 today.
Fingerprint #
fingerprint = base32(first 5 bytes of SHA-256("rim.device-link.v1 fingerprint\0" || offer plaintext bytes))
RFC 4648 base32, uppercase, no padding: 8 characters, shown as XXXX-XXXX. The new device
computes it over the bytes it sealed and the old device over the bytes it opened, so it binds the
whole offer (id, key, name, platform). Both screens show it; the person compares them.
Server rules #
Calls #
| Call | Caller | Transition | Repeats |
|---|---|---|---|
startDeviceLink |
Session; account has a key identity | → open |
Each call is a new link; the user's previous live link is cancelled |
sendDeviceLinkOffer |
Anyone with the link id | open → offered |
Same bytes while offered: success. Other bytes: refused |
deviceLink |
Session of the link's user | none | Returns state, expiry, and the offer while offered or answered |
sendDeviceLinkAnswer |
Session of the link's user | offered → answered; expiry set to now + 10 min |
Same bytes while answered: success, expiry not moved again |
deviceLinkAnswer |
Anyone with the link id | none | null while open or offered; the answer while answered |
completeDeviceLink |
Session of the link's user | answered → consumed |
true again while consumed |
cancelDeviceLink |
Anyone with the link id | open, offered, answered → cancelled |
See errors |
"Live" means open, offered or answered and not expired. Any session of the account may start
a link; only a device holding the identity key can finish one, which the server cannot check and
does not need to. completeDeviceLink is called by the new device right after loginWithKey, so
"the link's user" is the same account. consumed and cancelled set ended_at and clear the
sealed columns.
Every transition is one conditional UPDATE … WHERE link_hash = $1 AND state = <from> AND expires_at > now() RETURNING … (plus AND user_id = $2 for session calls), so two racing calls
cannot both succeed and a call that arrives after expiry loses. A zero-row update is then
classified (same bytes repeated, wrong state, expired, unknown) by one read.
Errors #
| Call | Situation | Code |
|---|---|---|
| Any | Over a rate limit | RATE_LIMITED |
sendDeviceLinkOffer, sendDeviceLinkAnswer |
nonce not 12 bytes, ciphertext under 17 bytes or over its cap, not strict base64url (checked before the link is looked up) |
VALIDATION |
| Anonymous calls | Link id malformed or unknown; link expired, cancelled or consumed; offer slot holds other bytes | UNAUTHENTICATED, no detail |
| Session calls | No session | UNAUTHENTICATED |
| Session calls | Link id malformed or unknown, or the link belongs to another user | NOT_FOUND |
startDeviceLink |
Account has no key identity | VALIDATION (this account has no key identity yet) |
sendDeviceLinkAnswer, completeDeviceLink |
Wrong state, expired, or other bytes already stored | CONFLICT |
cancelDeviceLink by the link's user |
Already consumed |
CONFLICT |
cancelDeviceLink with a session of the link's user follows the session rules, and answers
true for a link already cancelled or expired (an expired link keeps its state and still reads
EXPIRED); any other caller, including a session of another account, follows the anonymous
rules.
deviceLink reports an expired live link as EXPIRED until the row is cleaned up, then
NOT_FOUND.
Link ids #
The server generates 16 bytes from the OS random generator (128 bits, unguessable, so the uniform
UNAUTHENTICATED is about not leaking state, not about stopping guessing). It stores
link_hash = SHA-256(the 16 bytes), like session tokens and unlike the challenge nonce, because
the link id alone is a bearer capability (read the answer, post an offer, cancel) and a database
read must not hand those out. Rows have their own UUID v7 id; audit logs carry that and the user
id, never the link id or sealed bytes (events device_link_started, _offered, _answered,
_completed, _cancelled).
Sealed bytes #
The server checks only the encoding and the sizes above, stores the decoded bytes, and returns them re-encoded (byte-identical, since decoding was strict). It never decrypts, parses or inspects them, and has no key that could.
Limits #
| What | Limit | Key |
|---|---|---|
startDeviceLink |
10 per hour | user |
| Live links | 1; starting a new one cancels the previous | user |
sendDeviceLinkOffer + anonymous cancelDeviceLink |
30 per 15 minutes | IP |
deviceLinkAnswer |
300 per 5 minutes (a poll every 2 s is 150) | IP |
Anonymous refusals (UNAUTHENTICATED) |
20 per 15 minutes, a slot reserved up front and refunded unless the call ends in UNAUTHENTICATED (so successes, VALIDATION and internal errors do not count), as for key login |
IP |
The refusal limiter is separate from key login's, so a botched link does not lock the same
network out of loginWithKey. Limiters are the in-memory RegistrationRateLimiter, per node;
correctness never depends on them.
Expiry and cleanup #
- A link expires 10 minutes after
startDeviceLink.sendDeviceLinkAnswerresets expiry to 10 minutes after the answer, so a slow new device still has time to fetch it. - Expired rows are never returned with sealed bytes. Every
startDeviceLinkdeletes, best effort, rows whoseexpires_atis more than one hour past; the hour lets the old device readCONSUMED,CANCELLEDorEXPIREDafter the fact.
Storage #
A table, behind DeviceLinkRepository (rim_core), not an in-memory store. The two devices'
requests may reach different nodes, and the roadmap guardrail forbids features that depend on
single-node semantics; key_challenge is a table for the same reason. The trait is the seam if a
deployment ever wants another store. Migration add_device_link (generated with
sea migrate generate add_device_link):
CREATE TABLE device_link (
id uuid PRIMARY KEY,
link_hash bytea NOT NULL,
user_id uuid NOT NULL,
state text NOT NULL DEFAULT 'open',
offer_nonce bytea NULL,
offer_ciphertext bytea NULL,
answer_nonce bytea NULL,
answer_ciphertext bytea NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
offered_at timestamptz NULL,
answered_at timestamptz NULL,
ended_at timestamptz NULL,
CONSTRAINT uq_device_link_link_hash UNIQUE (link_hash),
CONSTRAINT chk_device_link_link_hash CHECK (octet_length(link_hash) = 32),
CONSTRAINT chk_device_link_state
CHECK (state IN ('open', 'offered', 'answered', 'consumed', 'cancelled')),
CONSTRAINT chk_device_link_offer CHECK ((offer_nonce IS NULL) = (offer_ciphertext IS NULL)),
CONSTRAINT chk_device_link_answer CHECK ((answer_nonce IS NULL) = (answer_ciphertext IS NULL)),
CONSTRAINT fk_device_link_user FOREIGN KEY (user_id)
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX idx_device_link_expires_at ON device_link (expires_at);
CREATE UNIQUE INDEX uq_device_link_live_user ON device_link (user_id)
WHERE state IN ('open', 'offered', 'answered');
startDeviceLink locks the user row, sets every live-state row of the user to cancelled (expired
ones included, so the partial index never blocks), and inserts the new row in one transaction.
Rollback. down runs DROP INDEX uq_device_link_live_user, DROP INDEX idx_device_link_expires_at, DROP TABLE device_link. Nothing references the table, and what it
loses is links at most 20 minutes old: a person mid-link starts again. Devices already added stay
in device_key.
Code: a device_link module in rim_core, rim_services, rim_infra_services and
rim_infra_db. DeviceLinkService does not depend on KeyAuthService; it reads only
user.principal_id.
GraphQL #
input SealedMessageInput { nonce: String! ciphertext: String! }
type SealedMessage { nonce: String! ciphertext: String! }
enum DeviceLinkState { OPEN OFFERED ANSWERED CONSUMED CANCELLED EXPIRED }
type StartedDeviceLink { linkId: String! expiresAt: DateTime! }
type DeviceLink { state: DeviceLinkState! expiresAt: DateTime! offer: SealedMessage }
type Query {
deviceLink(linkId: String!): DeviceLink! # session; the link's user
deviceLinkAnswer(linkId: String!): SealedMessage # no session; null until answered
}
type Mutation {
startDeviceLink: StartedDeviceLink! # session
sendDeviceLinkOffer(linkId: String!, offer: SealedMessageInput!): Boolean! # no session
sendDeviceLinkAnswer(linkId: String!, answer: SealedMessageInput!): DeviceLink! # session
completeDeviceLink(linkId: String!): Boolean! # session
cancelDeviceLink(linkId: String!): Boolean! # either side
}
- CSRF.
sendDeviceLinkOfferjoinsCSRF_EXEMPT_MUTATIONS: it is used without a session and acts only on a capability a cross-site page does not have.cancelDeviceLinkjoinsCSRF_EXEMPT_WITHOUT_SESSION: with a session cookie it may act as the link's user, so it needs the header. The other mutations need it as usual. - SDL order. The resolvers are new
DeviceLinkQueries/DeviceLinkMutationsobjects appended to the last query and mutation groups. - Polling. Clients poll every 2 seconds for the first minute, then every 5 seconds. A later
deviceLinkChanged(linkId)subscription over the existing graphql-ws socket can replace both polls; the anonymous side needs per-connection limits on that socket first.
Client rules #
Old device #
The device that holds the identity key, desktop or web (key-identity-v1.md, "Web client"). A
web client keeps the pending link record of rule 5 in IndexedDB instead of the keyring.
- Linking is offered only when this device holds the head's identity key
(
LogStatus.holdsIdentityKey). It generates the secret, callsstartDeviceLink, and shows the QR code, the text code with a copy button, and the time left. The secret stays in memory. - When
deviceLinksaysOFFERED, it opens the offer. A failure to open or parse means someone other than the intended device used the code: it callscancelDeviceLinkand says so. - Before asking the person, it fetches
myKeyIdentity, replays the log, and refuses (and cancels) an offer whosedeviceKeyis malformed, weak, of an algorithm it cannot verify, equal to the identity or recovery key, or used by any device of the account (revoked included); whosedeviceIdis not a UUID or is used by any device of the account; or whosenamebreaks the certificate rules. - It shows the name (editable before signing), the platform, and the fingerprint, and asks the
person to compare the fingerprint with the new device's screen. For
webit says that keys in a browser are weaker than on a desktop. Declining cancels the link. - On confirmation it writes a pending link record to the keyring (link id, secret, device
id, certificate), signs the certificate at the head
keySeq, and callsaddDeviceKey. ACONFLICTwhose device list shows this device id with this key (a retry after a lost answer) continues with the stored certificate; any other refusal cancels the link. - It seals the answer, calls
sendDeviceLinkAnswer, and only then republishes the profile card (best effort, as after enrollment), so a card failure never holds up the new device. - It polls until
CONSUMED, says the device is linked, and deletes the pending record. If the link endsCANCELLEDorEXPIREDafter the device was added and the device'slastUsedAtis null, it revokes the device with the identity key, so no certified key is left that nobody used. - On start-up with a pending record:
OFFEREDsends the answer again (adding the device first if the device list lacks it),ANSWEREDkeeps polling, anything else applies rule 7.
New device (desktop) #
- Reads the pasted code (desktops do not scan QR codes in v1) and refuses an endpoint that is
not the app's configured one (compared as the key store does: trimmed, trailing
/ignored). - Creates the device key and a UUID v7 device id in memory and seals the offer. It writes nothing to the keyring until the answer verifies, so an abandoned link leaves no keys behind.
- Sends the offer and shows the fingerprint with "Confirm this code on your other device".
UNAUTHENTICATEDmeans the code expired, was cancelled, or another device used it first; the app says so and suggests cancelling on the other device and starting again. - Polls
deviceLinkAnswer.UNAUTHENTICATEDmeans the link ended: the keys are dropped. - Opens the answer and checks that the certificate is a
devicestatement naming exactly the offer'sdeviceIdanddeviceKey. It fetcheskeyLog(principalId)from the endpoint, replays it, and verifies the certificate against the identity key at itskeySeq(certified_device). Any failure cancels the link and drops the keys. - Stores the device key, id and the certificate's name for the principal (no identity key,
account link with user id unknown, as after a recovery), signs in with
keyLoginChallenge+loginWithKey(audience from the endpoint), settles the keys, and callscompleteDeviceLink. A failedcompleteDeviceLinkis not retried beyond once: the device works, and the old device keeps it becauselastUsedAtis set. - A
loginWithKeyrefusal ofFORBIDDEN(the old device revoked it meanwhile) removes the keys; other failures keep them for the next sign-in.
Web client #
As the old device, the web client follows "Old device" when it holds the identity key: it signs the certificate with its non-extractable identity key. A web client that is itself a linked device holds no identity key and links no other device. As the new device, it follows the new desktop device, except:
- It only takes a pasted code, and only for its own GraphQL endpoint (cookies and CORS would fail for another).
- The device key is a WebCrypto Ed25519 key generated with
extractable: falseand kept as aCryptoKeyin IndexedDB; the public key is exported raw and taggeded25519:. A browser without Ed25519 in WebCrypto cannot be linked; there is no fallback to extractable keys. - HKDF and AES-GCM run in SubtleCrypto:
importKey("raw", secret, "HKDF"),deriveKey({ name: "HKDF", hash: "SHA-256", salt, info }, …, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]), thenencrypt({ name: "AES-GCM", iv, additionalData, tagLength: 128 }), whose output is already ciphertext followed by tag. platformisweb. The secret lives only in the page; a reload abandons the attempt.
Failure and retry #
| Situation | What happens |
|---|---|
Answer to sendDeviceLinkOffer lost |
The new device resends the same bytes: success |
Answer to deviceLinkAnswer lost |
It polls again; the answer stays for 10 minutes after it was sent |
Old device crashes after addDeviceKey, before the answer |
On restart, its pending record resumes the link (old device rule 8). If the link expired first, the new device sees UNAUTHENTICATED, drops its keys, and the person starts a new link, for which the new device makes a new device id and key: the old pair is used on the account for good. The old device revokes the unused one (rule 7) |
| New device crashes or the page reloads | Its keys were only in memory. The person starts again; the old device revokes the unused device |
| Wrong secret (corrupted code) | The offer fails to open; the old device cancels; the new device's poll gets UNAUTHENTICATED; the person starts again |
| Bogus offer from someone without the secret (the server, a log reader) | Fails to open; the link is cancelled; nothing is signed |
| Someone who saw the code posts first | The real device's offer is refused and it says so; the old device shows a name and fingerprint that match nothing on the person's new device, and the person declines. If they confirm anyway, that party gets a device of the account, as with any confirmed link |
| Someone who saw the code opens the answer | They get the certificate, which is public anyway (myKeyIdentity, the profile card). Without the device key it signs nothing in |
| Malicious server | Can drop, delay, replay or cancel. Cannot open or forge sealed messages, move them between links or directions, or forge a key log, so a substituted certificate fails the keyLog check |
| Person declines on the old device | cancelDeviceLink; the new device's poll ends with UNAUTHENTICATED |
Deferred #
- The archive key in the answer. It would let the new device read the drive archive without
the passphrase, but the answer key is known to anyone who saw the code, so a secret in the
answer must be encrypted to a key only the new device holds: an ephemeral key-agreement key in
the offer, which the post-quantum decision makes hybrid (X25519 + ML-KEM-768). The archive
format already reserves the mechanism (a new
keyWrapskind,history-archive-v1.md). A later code version adds both; until then the new device unlocks the archive with the passphrase, and it needs the folder or drive sign-in in any case. - Moving the identity key off the enrolling device, or holding it offline. A browser-held identity cannot move at all until then: its key is not extractable and is never backed up, so linking gives another device access to the account, not the identity.
- Short typed codes with a PAKE (CPace, SPAKE2), so a code can be read aloud.
- QR scanning, with the mobile PWA; the code format is fixed now so the QR works then.
- A subscription in place of polling (see GraphQL).
- Linking across servers (a code whose endpoint is not the new device's server).
- Rate limits shared across nodes.
Test plan #
Server (this PR):
rim_identity::device_link, the reference implementation of this document (code parse and format, key derivation, seal, open, fingerprint, offer and answer types), built only with the crate'stestingfeature. The service never calls it; tests do. Its tests check appendix A byte for byte, that an offer does not open as an answer or under another link id, that a flipped AAD, nonce or tag byte fails, and the code parser's edge cases (whitespace, uppercase scheme, version 2, 335 characters, padded or 21-character link id,?,#or@in the endpoint, colons in the endpoint).- Unit tests of
DefaultDeviceLinkServicewith a mock repository: every row of the calls and errors tables, idempotent repeats, expiry at the boundary, answer resetting expiry, a secondstartDeviceLinkcancelling the first, the per-user and per-IP limiters (injected small, likewith_rate_limiters), refunds on success. rim_infra_db(db-tests): two concurrent offers on one link, exactly one wins; transitions refuse expired rows; cleanup deletes only rows past one hour; terminal states clear sealed columns; the partial unique index; cascade on user delete; migration up and down.rim_api/tests/device_link_e2e.rs(e2e-tests), with real sealed messages fromrim_identity::device_link: the whole flow fromstartDeviceLinktologinWithKeywith the linked device andCONSUMEDon the old side; anonymous calls on unknown, malformed, expired, cancelled and consumed links allUNAUTHENTICATEDwith identical bodies; another user's linkNOT_FOUND; answer before offerCONFLICT; random ciphertext stored and returned byte-exact; size and encodingVALIDATION;startDeviceLinkwithout a key identity.graphql_support:sendDeviceLinkOfferexempt from CSRF;cancelDeviceLinkexempt only without a session cookie; the other mutations need the header.
Clients (rim-ui):
- Appendix A in the Tauri module and, for the web client, in Vitest through Node's WebCrypto.
- Old device: each refusal in rule 3; confirmation declined cancels; a lost
addDeviceKeyanswer continues with the stored certificate; restart with a pending record in each state; an unused device revoked afterEXPIRED. - New device: a certificate for another key, another device id, or one that does not verify
against the served log is refused and the link cancelled; nothing is in the keyring before the
answer verifies;
FORBIDDENat sign-in removes the keys.
Appendix A: known-answer vectors #
Computed with Node's crypto and cross-checked in Python (HKDF and SHA-256 with hmac and
hashlib, AES-GCM with cryptography).
The new device is appendix B's device of key-identity-v1.md (seed 20, device id
…0001, name Laptop), and the answer carries appendix B's certificate unchanged. Never use
these values for real links.
| Item | Value |
|---|---|
Link id (bytes 00..0f) |
AAECAwQFBgcICQoLDA0ODw |
Secret (bytes 20..3f) |
ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8 |
Code (endpoint http://localhost:8080/api) |
rim-link:1:AAECAwQFBgcICQoLDA0ODw:ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8:http://localhost:8080/api |
K_offer (hex) |
6dff67bf777c3cf073ebc0f8810dae7535d3bdb59c9178183441db3768212fba |
K_answer (hex) |
d6ec24d3f4380cb2ca50865f95a37f3e6b0ab9c2d74ee31d605768abe6bf56e1 |
| Offer AAD (hex) | 72696d2e6465766963652d6c696e6b2e7631006f666665720041414543417751464267634943516f4c4441304f4477 |
| Answer AAD (hex) | 72696d2e6465766963652d6c696e6b2e763100616e737765720041414543417751464267634943516f4c4441304f4477 |
Offer nonce (bytes 40..4b) |
QEFCQ0RFRkdISUpL |
Answer nonce (bytes 50..5b) |
UFFSU1RVVldYWVpb |
| Fingerprint | QSFX-VHMA (SHA-256 848b7a9d80a01b87adceab1630b57a452359598c53e4326382d7a236e3ca8629) |
Offer plaintext (201 bytes) and its ciphertext (217 bytes, tag 4e654172510e4140794b15c8b0ed70b1):
{"v":1,"type":"link-offer","deviceId":"00000000-0000-0000-0000-000000000001","deviceKey":"ed25519:IIKL9cW9ystoSGMzbCAvtVmdpIvlWWYVdCFwcFvsqfc","name":"Laptop","platform":"desktop","statementVersion":2}
J8-HezTgXKj9OyT7HMBA1zjjB4ZTJEJkqjh4skQfSya0Sxe6uk9qWLSzUqUAQ7QrC4ZIEYlO_dsskNkfhQ_iEVXyyUkGmH2j1gqSNpQTetjmsgmQEXUbEsSs8ciJPmD_o_T8zR_R7k-rjthHmMQYHVfwslbojzQhR7wXQiUJfZtIDm5bfx9wYmPPjvmcA9RYQnV6_3GRGoFbsLE9fGt4mRlidnXDiOweblWqdi0dARTNJHJL7DDAMwmqGHiEIcAVTVu7B4SiHFPrTmVBclEOQUB5SxXIsO1wsQ
Answer plaintext (460 bytes) and its ciphertext (476 bytes, tag 54f5a552761c925f6e4ac7ac5d45abb7):
{"v":1,"type":"link-answer","certificate":{"payload":"{\"v\":2,\"type\":\"device\",\"principalId\":\"rimp1_txDLbnipi5i3kCJtJwBOUixzv_MY8OnU0mcy--Tyje0\",\"deviceId\":\"00000000-0000-0000-0000-000000000001\",\"deviceKey\":\"ed25519:IIKL9cW9ystoSGMzbCAvtVmdpIvlWWYVdCFwcFvsqfc\",\"name\":\"Laptop\",\"keySeq\":0,\"issuedAt\":\"2026-10-09T00:00:00Z\"}","signature":"ed25519:0HjRzn6gQ2q-vCHSkdOPK2ykGGBUpW2fYdDuEdTzf6OxsWzjXeNNpRSVgzPxOEfbYzZ9o9DWqp4NxcTEDhOaDA"}}
vkDrDIh3nkBRDHroeH_tmxnHXtoMzfo4grqtAiBG46z7xMsAlJDtvvExm51M7w7G7oglt9YXamu3_qjKYYDPA7b_xGvbPvWGkw98Hc91oL2qQvVYz0MSwIWu5R_FyFwN0SIVdph0xJxLBdXq3E3-iKFd4fdfLRdZvRi7qMMTY7wW8DMw2ItevB1tV2xfMF2_WpZNSUvPuJUF9l8VIdtiIHO1V94Sx-ombzLAvfndFHJfxKhsDwxPB9YSaKoaIvKPZrI_6Z0Mzwdn7RAyepsgjrOz4wDnoEWv5eFOO1zU4RpZvRwOmlhJ2NKwLsG87fEtw16kB3g2qMqvwGLWrQ4VuqygY3JucPE0Gu2H-dkxLEYrEuPMbGxMzD2ROIAlZFKljdgj8zGLrYbkLml1Jh-rJijDswN0ktaw-dCCqd0-Pbt4nXvBKGTV5ZuDJ1W-nIMYs9Py81klr5gBOypP8A3In4OFi7ePsu2FOnZE-LRYfaLeYv2PCPKCFaZnCjJS0asVa-UFXHhmz2sWxVwEi1OnENi11C4bweM6a_9ydUHi4w4QMBkZpfHCyondEdgCtxyq8xZXPsEP6_JX007Y9jm9gGQ8JExrsviYRMpeWFT1pVJ2HJJfbkrHrF1Fq7c
The plaintexts are written with the fields in the order shown and no spaces, which is what makes these bytes reproducible; readers do not depend on field order.