32 KiB
ADR-0022 — Desktop QR pairing protocol ("scan to sign in")
Status: Accepted, 2026-05-04 Supersedes: none Superseded by: none
Context
Users who already have Morphit working on their phone (keys in the phone's keystore) want to sign into Morphit on a desktop browser they don't normally use — a friend's laptop, a public terminal, their own desktop after wiping the browser profile, etc. The naïve solution is "type your seed phrase on the desktop," which is unsafe (seed phrase on a strange machine = compromise) and grandma-hostile (users hate typing 12-24 word phrases under pressure).
The other naïve solution most crypto products ship — "scan this QR with your phone, the QR contains your seed phrase" — is actively harmful because the desktop now holds the seed. We will not ship that pattern.
What we want is the third pattern: the desktop holds an ephemeral session, the phone retains the keys, and a one- time signed handshake links them. This ADR specifies that protocol.
Decision
Ship a relay-mediated ephemeral-pairing protocol where:
- The desktop generates an ephemeral X25519 keypair and nonce, encodes the public half + a pairing ID + the originUrl into a QR code.
- The phone scans the QR, validates the originUrl against what the user is actually trying to log into, prompts the user to confirm with a high-friction confirmation card, and signs a pairing bundle with the phone's posting key. The signed bundle is encrypted to the desktop's ephemeral X25519 pubkey using the same ECIES construction as ADR-0015 chat crypto.
- The phone POSTs the encrypted bundle to the relay's
/v1/login-pairing/:pairingId/deliverendpoint. - The desktop, listening on the relay's
/v1/login-pairing/:pairingId/waitSSE endpoint, receives the bundle, decrypts with its ephemeral private key, verifies the signature against the account's on-chain posting pubkey, and accepts the session.
The desktop ends up with a session credential — same shape as today's "logged-in identity" — with the chat- pubkey + accountName pinned. The desktop does not gain the posting key itself; write actions still require unlock-on-broadcast, just like sessions created the conventional way.
The relay sees only ciphertext routed from one party to another, addressed by an opaque pairingId. It learns: that pairing X had a bundle of N bytes pass through it. It does not learn account names, signatures, or plaintext.
Why this design
Three options considered
Option A — fully P2P with two cameras. Desktop renders QR, phone scans → phone signs → phone renders second QR, desktop's webcam scans. No relay involvement at all.
- ✅ Strongest privacy: relay learns literally nothing.
- ❌ Requires desktop webcam + holding phone up to it.
- ❌ Two QR-scan steps fail-rate-stack: each scan ~5% user-failure (lighting, focus, angle), so two-step flow ~10% failure rate. Not grandma-friendly.
Option B — relay-mediated, one camera. Desktop renders QR, phone scans → phone signs → phone POSTs encrypted bundle to relay → relay shuttles to desktop over SSE. This is what we ship.
- ✅ One camera step, commodity flow (matches what users see on Discord, WhatsApp Web, Signal Desktop).
- ✅ Relay sees only ciphertext addressed by opaque pairingId.
- ✅ Same primitives as ADR-0015 chat crypto — no new cryptographic surface.
- ❌ Relay learns "a pairing happened at time T with N bytes." Acceptable: same metadata-leakage threshold as chat-message routing.
Option C — phone delegates a posting subkey to the
desktop. Phone broadcasts a Blurt account_update op
adding a new posting authority (a key the desktop
generated locally) with a TTL or revocation marker.
- ✅ Strongest UX: desktop becomes a fully independent posting device, no phone needed for write operations after pairing.
- ❌ The delegation itself is public on chain. Anyone
watching
@grandma's account sees a new posting key was added. For Morphit's threat model where on-chain reputation IS the reputation, this is acceptable for some users and unacceptable for others. - ❌ Costs a chain operation fee.
Decision: ship B as default. Document C as a future opt-in for power users who want desktop-as-fully- independent (a follow-up ADR can specify it; we do not need to ship both paths simultaneously).
Why not signed JWTs / WebAuthn / passkeys?
Considered:
- WebAuthn / passkeys would require operator-side account state (the relying party stores user-handles to credential-IDs), which violates Morphit's no-IP- retention / no-account-state posture. The relay can't be a passkey RP because it doesn't have user records.
- Signed JWTs would require operator-side key storage to sign session tokens, and a centralized JWT signer is exactly the federated-operator anti-pattern.
- OAuth-style flows require an identity provider, which Morphit deliberately doesn't have.
The chosen design uses the user's posting key (the authority that's already authoritative for Morphit) to prove "I authorize this desktop session," which is the right cryptographic primitive for our trust model.
Protocol detail
Pairing payload (desktop → QR)
{
"v": 1,
"pid": "<32-byte hex>",
"epk": "<base64 X25519 pubkey, 32 bytes>",
"origin": "https://morphit.io",
"exp": 1714867200,
"relay": "https://morphit.io"
}
Fields:
v— protocol version (currently 1).pid— pairing ID. SHA-256 of(epk || nonce). Used by the relay to route bundles. Treated as opaque by everyone; uniqueness enforced by the relay's in-flight registry.epk— desktop's ephemeral X25519 public key. The phone encrypts to this; only the desktop holds the matching private key. Generated fresh for every pairing attempt; never reused.origin— the canonical origin the user is trying to log into. The phone validates that the page it's about to authorize is THIS origin. Lets us catch homoglyph phishing (morph1t.io).exp— Unix-seconds expiry. Capped at 5 minutes after generation (validated by the relay; bundles delivered afterexpare rejected).relay— the relay URL the phone POSTs to. In a federated world this lets a user on Operator A pair into Operator B's instance — phone delivers the bundle to the operator the desktop is sitting on.
QR encodes this as a single base64url string of compact JSON. ~250 bytes — comfortably within QR limits even at the conservative error-correction level we'll use.
Phone-side validation (before showing the
confirmation card)
Phone parses the QR. Rejects if:
v !== 1.pidis not 64 hex chars.epkis not 32 bytes after base64url decode.originis not a validhttps://URL with a host component.expis in the past or more than 5 minutes in the future.relayis not a validhttps://URL.
If any of the above fails, phone shows a generic "this QR isn't a valid Morphit login QR" error and refuses to proceed.
Phone-side confirmation card
If the QR validates, the phone shows:
┌─────────────────────────────────────────┐
│ │
│ Sign in to Morphit? │
│ │
│ A computer is asking to sign in │
│ as @<your-account-name>. │
│ │
│ Website: morphit.io │
│ Started: 2 minutes ago │
│ │
│ Only tap "Yes, that was me" if you │
│ just opened this site on your │
│ computer. │
│ │
│ [ No, I didn't ] [ Yes, that was me ] │
│ │
└─────────────────────────────────────────┘
- The website hostname is shown faithfully (no pretty-
printing, no truncation, no smart-quoting). A
homoglyph attack like
morph1t.ioshows up asmorph1t.io, notmorphit.io. - The default-focus button is "No, I didn't" — pressing Enter or accidentally double-tapping does NOT confirm. The user must reach for the affirmative button intentionally.
- "Started" is a relative time computed from
now() - exp. Helps catch stale QRs (e.g. an attacker showing the user a 4-minute-old screenshot). - No "remember this device" checkbox. Every pairing is a fresh consent moment.
Phone-side bundle construction
After the user taps "Yes, that was me":
plaintext_bundle = canonicalJson({
v: 1,
pid: <pairing id>,
epk_echo: <epk as received, base64>,
origin_echo: <origin as received>,
account: <phone account name>,
account_chat_pubkey: <phone's chat-identity pubkey>,
signed_at: <Unix seconds>,
device_label: <phone OS / model, ASCII-only, ≤32 chars>
})
# Domain-separated signing digest. The phone hashes the
# canonical bundle bytes prefixed with a fixed string so a
# pairing signature can never be replayed as a chain
# transaction signature, and vice versa. The trailing
# newline keeps the prefix a fixed-length string that can't
# be confused with the JSON structure (which begins with `{`).
SIGNING_DOMAIN_PREFIX = "morphit-pairing-v1\n"
digest = SHA-256(SIGNING_DOMAIN_PREFIX || plaintext_bundle_bytes)
# Signature is over the digest, not over the raw bytes.
# Producers MUST hash with the prefix; verifiers MUST hash
# with the same prefix. A phone signing the raw bytes (or
# any other digest construction) will produce signatures the
# desktop verifier rejects.
signature = secp256k1_sign(posting_priv, digest)
signed_envelope = canonicalJson({
bundle: plaintext_bundle,
signature: <hex of (recovery+31 || r || s) — 65 bytes>
})
ciphertext = ChaCha20-Poly1305-IETF(
key = BLAKE2b-256(
X25519(phone_ephemeral_priv, epk),
info = "morphit-pairing-v1/aead-key"
),
nonce = random_12_bytes,
plaintext = signed_envelope,
aad = pid_bytes
)
delivery_payload = canonicalJson({
v: 1,
pid: <pairing id>,
ephemeral_pub: <phone's X25519 ephemeral pubkey, base64>,
nonce: <12 bytes, base64>,
ciphertext: <ciphertext + auth tag, base64>
})
Canonical-JSON serialization. Both the bundle and the
envelope are serialized with sorted-key canonical JSON so
producers and verifiers agree on byte-exact serialization
across runtimes. Implementation: canonicalJson(value) in
apps/web/src/lib/auth/desktopPairing.ts — sorts object
keys lexicographically before emission.
Phone → Relay delivery
POST /v1/login-pairing/:pairingId/deliver
Body: delivery_payload JSON, Content-Type: application/json. Body size capped at 4 KiB (well above
needed; loose enough that a future device_label
expansion doesn't break clients).
Relay validates:
pidin URL matchesdelivery_payload.pid.- The pairing is in the relay's in-flight registry.
Registry holds an entry from the moment the desktop
starts subscribing on
/waituntil either delivery orexp(whichever first). - Bundle hasn't already been delivered to this pid. Single-shot: a delivered pid is immediately deleted.
Relay does NOT validate signatures or decrypt. It's a dumb pipe.
Relay → Desktop delivery
Desktop subscribes via SSE: GET /v1/login-pairing/:pairingId/wait.
Connection times out at exp - now(), max 5 minutes.
When a bundle arrives, relay pushes it as a single SSE event:
event: bundle
data: {"v":1,"pid":"...","ephemeral_pub":"...",...}
Then closes the connection. Pid is deleted from registry immediately.
Desktop-side verification
Desktop:
-
Validates the bundle's
pidmatches the one this desktop is waiting for. (Defense against any cross- subscription leakage in the SSE handler.) -
Computes
key = X25519(desktop_ephemeral_priv, delivery_payload.ephemeral_pub). -
Decrypts with ChaCha20-Poly1305, AAD=pid.
-
Parses the inner
signed_envelope. -
Validates
bundle.epk_echo === desktop_ephemeral_pub(defense against a relay shuffling pids — the bundle has to claim to be FOR this desktop's epk, not just delivered to this pid). -
Validates
bundle.origin_echo === window.location.origin(defense against a relay passing a bundle that was signed for a DIFFERENT origin — the user might have approved amorph1t.iopairing on their phone, but if their desktop is onmorphit.iowe refuse). -
Validates
bundle.signed_atis within[now-2min, now+30s]. The 2-min window covers reasonable phone↔relay↔desktop hops; the +30s tolerates minor clock skew on the phone. -
Fetches
bundle.account's on-chain posting authority viacondenser_api.get_accountsthrough the existing chain rotator. Computes the SAME domain-separated digest used by the phone:digest = SHA-256(SIGNING_DOMAIN_PREFIX || canonical_bundle_bytes). Recovers the signing pubkey via secp256k1 from(digest, signature). Looks up the recovered pubkey inposting.key_authsand requires its weight to clearposting.weight_thresholdwith a single signature.Multisig limitation. The pairing protocol carries ONE signature. Accounts whose posting authority requires multiple signatures (multisig:
key_authsweights summing to threshold but no single key carrying it alone) cannot pair via QR with this version. Multisig users fall back to seed-phrase import. Single-sig accounts (the common case) are fully supported. -
If everything passes: writes the session credential (
account+account_chat_pubkey+ a freshsession_id+started_at+device_label_remote) to local storage, navigates to the user's home.
If any step fails: shows the user a generic "couldn't verify the sign-in. Please try again." message and resets the desktop's QR to a fresh pairing. Detailed failure reasons go to local console for debug, NOT to the user — error-message-as-attack-channel concern.
Threat model
What an attacker can do, and what we do about it
A1 — Attacker shows the user a phishing page that
generates a pairing on their own controlled
relay. User scans, phone shows confirmation card.
Defense: the confirmation card shows the origin
URL faithfully. User trained to look at the URL
catches morph1t.io. This is the same defense as any
phishing-aware login flow; not perfect but the user has
the information they need.
A2 — Attacker captures a screenshot of the user's desktop QR (over-the-shoulder, screen-share leak, malware screen capture). Attacker tries to deliver a bundle to the pid. Defense: the attacker doesn't have the user's phone, so they can't sign. The QR alone is NOT a credential; it's a request for a credential. An attacker who captures it can ONLY race to deliver a forged bundle, which fails signature verification.
A3 — Attacker on the same LAN as the desktop tries to race-deliver their own bundle. Defense: the attacker would need to encrypt to the desktop's epk (which is in the QR — public, but only useful if you can also forge a posting-key signature). They'd also need the user's account name to forge a plausible bundle, which they may guess. But the signature check fails because they don't have the posting key.
A4 — Compromised relay tries to inject its own bundle
to a pid. Defense: same as A3 — the relay can't
forge a posting-key signature. The bundle's signature
verifies against the on-chain pubkey for bundle.account
which the relay doesn't control.
A5 — Compromised relay tries to LEAK plaintext metadata. Defense: it sees the pid (random), delivery-bundle size (~1 KB always, padded if needed), and timing. It does NOT see the account name, the origin, or any plaintext. Same metadata threshold as chat routing.
A6 — User trains themselves to tap "Yes" without reading. Defense: the confirmation card design makes the URL prominent and the "No" button is the default-focused one. We can do nothing about a user who tunnels through a security UX without reading. Document this in SECURITY.md and the FAQ.
A7 — Replay attacks. Defense: signed_at
freshness window in the bundle (≤2 minutes old, ≤30s in
future); single-shot pid (delete after first delivery);
QR exp (≤5 minutes after generation).
A8 — Desktop session credential is stolen by malware on the desktop after pairing. Defense: this is true of any login system; not our problem to solve. Our mitigation: short session TTLs (24 hours default), the session permits read-only access; write actions still require unlock-on-broadcast (existing behavior, not changed by this protocol).
A9 — User loses phone, can't log in to desktop. Recovery path: paper-key recovery (already documented in OPERATIONS §9). The QR-pairing flow does NOT replace paper-key recovery; it complements it.
What we DON'T defend against
- Cross-site scripting on the morphit.io domain. If an attacker can run JS on the legitimate origin, they can substitute their own ephemeral pubkey into the QR and intercept the bundle. Defense is upstream: CSP, no-eval, no-third-party-CDN, all already in place.
- Compromised phone keystore. If the attacker has the user's posting key, they ARE the user as far as Morphit is concerned. No protocol can save us from that.
- Operator running a hostile relay that selectively drops pairings to deny service. Federation answer: switch operators.
Operator-side requirements
- New endpoint pair on the indexer (where SSE
infrastructure already exists; the relay would need
a fresh SSE stack just for this feature, which is
unjustified):
POST /v1/login-pairing/:pid/deliverandGET /v1/login-pairing/:pid/wait(SSE). Body cap 4 KiB on deliver. Rate limit: tier 'resource' (default 60/min/IP for deliver). The SSE/waitdoesn't share the per-minute budget — same posture as/v1/orderbook/streamand/v1/chat/:a/:b/stream. - In-memory pid registry:
Map<pid, {epkPub: bytes, exp: number, deliveredAt: number | null, waiterStream: WritableStream | null}>. Hard cap on total in-flight pids (default 10000) to prevent memory exhaustion. - Janitor: every 30 seconds, delete entries where
exp < now().
The indexer does NOT persist pid state. An indexer restart loses any in-flight pairings (acceptable — users retry, worst case they wait the 5-minute QR expiry).
Wire-format & API stability
This is v: 1 of the protocol. Future versions:
- Compatibility: phone reading a
v: 2QR rejects cleanly with "your phone needs an update." - Versioning lives in the QR payload AND the delivery payload AND the session bundle. A phone can speak v1; a desktop can speak v1; both reject v2 cleanly.
- v2 considerations (not for now): adding optional device-attestation fields, supporting hardware-key- backed signing, supporting Option C (delegated posting subkey).
i18n
15+ new strings on the desktop side (initiator UI), plus ~10 on the phone side (scanner UI + confirmation card), plus ~5 error states. Total ~30 keys × 10 locales = 300 new translation lines. Confirmation-card prose is the most security-sensitive; native-speaker review post- launch is on the existing translation-QA backlog.
Smoke tests required
desktop-pairing-crypto-smoke: pure ECIES round trip, signature verify, pid derivation, bundle parsing, all validation gates exercised.desktop-pairing-relay-smoke: deliver-then-wait round trip, exp-expired rejection, double-delivery rejection, oversized-body rejection, malformed-JSON rejection.desktop-pairing-replay-smoke: bundle with stalesigned_atrejected; bundle with futuresigned_atrejected; bundle with mismatchedepk_echorejected; bundle with mismatchedorigin_echorejected.
Brag-list claim discipline
Per project standing instruction, brag-list claims must be verifiable in code. The brag-list claim that this ADR authorizes is MORPHIT-BRAG-LIST.md item #218 ("QR sign-in: scan with your phone, never type your seed on a strange computer"), shipped together with the implementation in Audit Part 30 (2026-05-04). The claim describes the threat model, primitives, smoke coverage, multisig honest- disclosure, and the cross-references this ADR documents.
Implementation (shipped Audit Part 30)
This ADR was implemented end-to-end in Audit Part 30 (2026-05-04). Shipped components:
- ✅ Pure crypto module
(
apps/web/src/lib/auth/desktopPairing.ts, ~600 lines). X25519 + BLAKE2b + ChaCha20-Poly1305-IETF, same primitives as ADR-0015 chat crypto. Signer-agnostic viaBundleSignerandSignatureVerifiertypes. Domain- separated AEAD-key derivation (morphit-pairing-v1/aead-key) and signing digest (SIGNING_DOMAIN_PREFIX = "morphit-pairing-v1\n") so pairing keys/signatures can never collide with chat, release-trust-anchor, or chain-transaction signatures. 29-scenario crypto smoke covers every gate including the buffer-wipe defense viasodium.memzero. - ✅ Indexer endpoint
(
apps/indexer/src/api/loginPairing.ts, ~280 lines).POST /v1/login-pairing/:pid/deliver(4 KiB body cap,'resource'-tier rate limit) +GET /v1/login-pairing/:pid/wait(SSE). In-memoryPairingRegistrywith single-shot enforcement, hard cap 10000 entries, 30s janitor. 12- scenario state-machine smoke. Mounted on the indexer rather than the relay because the indexer already has SSE infrastructure (orderbook/stream, chat/stream, instances/stream). - ✅ Desktop initiator UI
(
apps/web/src/lib/components/LoginQrInitiator.svelte)- route
/login/qr-pair. Linked from/loginin both branches.
- route
- ✅ Phone scanner UI with grandma-friendly confirmation card
(
apps/web/src/lib/components/ScanLoginQr.svelte)- route
/scan-login.qr-scanner@^1.4.2for camera decoding (~13KB minified, MIT, zero deps). Origin URL displayed faithfully; default-focus on "No, I didn't" button; "started X minutes ago" relative time.
- route
- ✅ Phone-side production signer
(
apps/web/src/lib/auth/pairingPhoneSigner.ts). Reads the unlocked posting key from$stores/identity, computes the domain-separated digest, signs via dblurt's secp256k1 primitive, returns the canonical 65-byte (recovery+31 || r || s) wire form. ThrowsPairingSignerErrorwith structured codes the scanner UI surfaces. - ✅ Desktop-side chain-backed verifier
(
pairingClient.defaultVerifierinapps/web/src/lib/auth/pairingClient.ts). Fetchesposting.key_authsvia the chain rotator, recovers the signing pubkey from the signature using the same domain-separated digest, requires single-signature weight-clearance. - ✅ i18n × 10 locales (~520 new translation lines):
login_qr.*(19 keys),scan_login.*(27 keys),login.qr_pair_cta,login.welcome_back.use_phone_instead, SEO entries for both routes. - ✅ FAQ entry × 10 locales (
faq.entries.qr_login). Grandma-honest prose with concrete homoglyph example (morph1t.io). - ✅ Brag-list claim #208 with honest disclosure of remaining limitations.
Final pulse on the shipping commit: Triple-stable 1952/0 across the smoke suite. Backend typecheck 0 errors / 7 workspaces. Frontend typecheck 0/0.
Pre-launch pending — what's NOT yet shipped
The protocol and code are usable end-to-end for single-signature posting authorities (the common case). The following are still backlog:
- Multisig support. Accounts with multi-key posting authority requiring multiple signatures cannot pair via QR. The protocol carries one signature; supporting multisig requires either (a) extending the bundle to carry multiple signatures from multiple signers, or (b) delegating to Option C (account_update with a fresh posting subkey). Filed as future work.
- "Type a 6-word phrase" QR-fallback path. Users whose phone QR scanner doesn't work would benefit from manually typing a short BIP-39-encoded version of the pairing payload. UI hook is in place; encoder/decoder not yet implemented.
- C-15 follow-up: explicit
dir="ltr"on the confirmation-card URL display element to harden against U+202E-style RTL-override attacks on RTL locales. - Option C (delegated posting subkey via on-chain account_update). Strongest UX (desktop becomes a fully independent posting device) but leaks "user added desktop session" as on-chain metadata. Documented as future opt-in for power users.
- Native-speaker review of the security-sensitive confirmation-card prose in 9 non-English locales.
- End-to-end integration test spinning up indexer + Postgres + headless browser. Current smokes cover the state machine + crypto round-trip; full E2E is follow-up.
Part 114 amendment — read-only desktop session (Option A, formalized)
This section closes the "session-establishment gap" left open at original-ADR time and amends the design with the concrete Option A semantics now shipped in code.
What was missing pre-Part-114
The original ADR (above) describes the QR-pair protocol end
to end: ephemeral keys, signed bundle, chain-backed verifier
(see apps/web/src/lib/auth/pairingClient.ts:defaultVerifier).
At 'received' state the desktop holds a verified proof that
the bundle was signed by the account's on-chain posting key,
and the verified envelope carries account + chatPubkey —
public state, no signing material.
But there was a real gap: nothing turned that proof into a
session. The 'received' handler called goto('/') with
no bootFromEnvelope-equivalent, so the user landed on the
homepage still locked. The verifier worked; the protocol
worked; the application-level sign-in did not.
The decision
After weighing three closures (see "Three options considered" above — fully P2P with two cameras, relay-routed envelope, phone-mediated remote signing), Part 114 ships Option A — read-only desktop session:
- A successful QR-pair handshake establishes a new identity-
store state
'paired-readonly'carrying{ account, chatPubkey, pairingId, pairedAt }. - The desktop is signed in for READ operations: orderbook, profile pages, chat history, settings (viewable), my orders, etc. Everything that doesn't sign with the posting key works exactly as it does for an unlocked session.
- The desktop CANNOT broadcast write operations from this
device. Every write call site (post order, send chat
message, leave feedback, share address, funds-sent,
account_update / profile edit, register account name) gates
on
$isUnlocked— which isfalseunder paired-readonly — and renders aWriteBlockedReadOnlyaffordance pointing the user back to Morphit on their phone via theweb+morphit://deep-link protocol handler. - The posting key NEVER leaves the phone. That is the strong privacy property the original ADR set out to preserve; Option A preserves it intact.
Why Option A over B and C
Option B (phone-mediated remote signing). Every write op on the desktop would round-trip back to the phone for signature, then to the relay for broadcast. Pros: full write capability on the desktop. Cons: doubles the latency of every write, requires the phone to be online when the user acts, adds significant new protocol surface (signing-request routing, replay protection, approval UI, lost-phone recovery). Filed as future work for users who specifically want it; not blocking launch.
Option C (delegated posting subkey via account_update).
The phone signs an account_update op that adds a fresh
posting subkey the desktop holds. Pros: desktop is fully
self-sufficient with no phone-online dependency. Cons: every
pairing event leaves an on-chain footprint visible to anyone
watching the account — the OPPOSITE of the privacy posture
this ADR set out to preserve. The original ADR (lines
94-103) explicitly rejected this approach for that reason;
Part 114 honors that decision.
Option A is the only closure that lands cleanly in one ship and preserves every privacy property the original ADR committed to. WhatsApp Web uses the same model ("phone is the source of truth; desktop is a window"), so users have a mental model for it.
What Option A delivers in code
| Component | Path | Role |
|---|---|---|
| Persistence module | apps/web/src/lib/crypto/pairedSession.ts |
Read/write/clear paired-readonly marker via safeLocal (Tor/Private-Mode-safe). Validator rejects malformed records. |
| Identity store | apps/web/src/lib/stores/identity.ts |
New 'paired-readonly' state, derived stores (isPairedReadOnly, pairedReadOnly, hasAnySession), bootFromPairedSession (refuses unlocked downgrade), exported autoRestorePairedSession() + handleStorageEvent() for module-load + cross-tab sync. |
| QR-pair UI | apps/web/src/lib/components/LoginQrInitiator.svelte |
On 'received': captures pid, calls bootFromPairedSession, persists morphit.blurtAccount, navigates to /orderbook. |
| Global banner | apps/web/src/lib/components/PairedReadOnlyBanner.svelte |
Slim emerald bar under the sticky header, always visible during paired-readonly sessions. |
| Write-blocked affordance | apps/web/src/lib/components/WriteBlockedReadOnly.svelte |
8 variants (post_order, send_chat, feedback, share_address, funds_sent, profile, register_name, generic). Deep-links to web+morphit:// protocol handler with preserved context (peer, orderPermlink). |
| Write call sites updated | /post, ConversationView, LeaveFeedbackForm, /settings, /onboarding/register-name |
Each gates on $isPairedReadOnly and shows the WriteBlocked affordance. |
| Welcome-back | /login |
Fourth formMode 'paired-readonly-welcome' shown when the store auto-restores a paired session. Two CTAs: continue read-only, or upgrade to keys (reset() + redirect to import). |
| AvatarMenu | apps/web/src/lib/components/AvatarMenu.svelte |
Renders for both unlocked AND paired sessions ($hasAnySession). Paired sessions get a small emerald indicator pill on the avatar and a "via phone (read-only)" pill above the menu items. Lock Session is hidden for paired sessions (canLock = hasPersistedKeystore() && !$isPairedReadOnly). |
Cross-tab semantics
Mirror of the existing keystore §F.17 cross-tab posture:
- Locked tab + sibling pairs → adopt paired marker.
- Paired tab + sibling clears paired marker → drop to locked.
- Unlocked tab + sibling pairs → ignore (unlocked is strictly more capable than paired-readonly; don't downgrade).
- Unlocked tab + sibling clears keystore → existing behavior
unchanged (drop to locked via
reset()).
The handler is handleStorageEvent(e) — exported so vitest
under jsdom can call it directly, since SvelteKit's browser
flag is false in test env and would otherwise skip the
listener registration. Same dispatch in production, just
testable.
Test coverage
apps/web/src/lib/crypto/pairedSession.test.ts— 21 tests covering round-trip, idempotent overwrite, clear, validator rejection (12 distinct shapes), Private Mode storage refusal.apps/web/src/lib/stores/identityPaired.test.ts— 15 tests covering bootFromPairedSession + lockSession + reset semantics, cross-tab adopt/drop/ignore, autoRestorePairedSession idempotency andmorphit.blurtAccountreconciliation.apps/web/scripts/paired-readonly-lifecycle-smoke.ts— 18 scenarios registered inscripts/run-smokes.shnext todesktop-pairing-crypto-smoke.- Triple-pulse smoke: 2,296 / 2,296 / 2,296.
- Full frontend unit suite: 586 passed, 5 pre-existing keystore-cross-tab skips, 0 failed.
Locale coverage
New paired_readonly block in all 10 locales with 16 keys
each (banner heading + body, 7 write-blocked variant bodies,
generic heading, action label, avatar menu pill, welcome-back
heading + body + continue + use-keys-instead). Locale parity
verified: 2,448 keys × 10 locales.
What's still NOT shipped (kept honest)
The same backlog items listed in the pre-Part-114 ADR remain open (multisig, 6-word phrase fallback, native-speaker review, full E2E with headless browser). Option B (phone-mediated remote signing) and Option C (delegated posting subkey) are intentionally NOT shipped — they're future work, gated on user demand and a separate ADR.
For Option A specifically:
- ✅ Verifier wired (chain-backed signature recovery).
- ✅ Session-establishment wired (bootFromPairedSession).
- ✅ Persistence + auto-restore wired.
- ✅ Cross-tab sync wired.
- ✅ Every write call site updated with the WriteBlocked affordance.
- ✅ AvatarMenu shows paired state.
- ✅ Welcome-back path handles paired-readonly first.
- ✅ Sign-out wipes paired marker.
- ✅ Test coverage end-to-end.
- ✅ All 10 locales at parity.
Option A is complete and shippable as of Part 114.