From ea29e1ea69ffa04acac590e7fba69f6371cdcdd3 Mon Sep 17 00:00:00 2001 From: Pitrick3141 Date: Wed, 12 Aug 2026 15:18:52 +0800 Subject: [PATCH] feat: Add QR code as a method to install device key --- README.md | 4 +- SECURITY.md | 6 +- apps/wallet/package.json | 2 + .../src/components/DeviceManagement.tsx | 452 ++++++++++++++---- apps/wallet/src/lib/device-management.test.ts | 33 ++ apps/wallet/src/lib/device-management.ts | 101 ++++ docs/protocol/device-delegation-v2.md | 26 +- pnpm-lock.yaml | 20 + 8 files changed, 554 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index a686724..1015e44 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ Nexus v1 intentionally provides no identity-key backup, portable private-key exp recovery service. The additive [v2 root/device profile](./docs/protocol/device-delegation-v2.md) preserves the same `nx1_` identity and non-extractable root while allowing that root to authorize independently revocable device signing keys. Only a device key may be copied through an encrypted -offline transfer bundle; the identity/root key is never exported. Existing v1 proofs and endpoints -remain unchanged. +offline transfer bundle or a locally rendered direct-scan QR credential; the identity/root key is +never exported. Existing v1 proofs and endpoints remain unchanged. ## Prerequisites diff --git a/SECURITY.md b/SECURITY.md index 21b8d10..fe5edd0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,9 +51,9 @@ Changes must preserve the normative requirements in `NEXUS_SPEC.md`, especially: - no third-party runtime JavaScript on the wallet origin. The additive v2 profile permits encrypted offline transfer of a newly generated device private key, -not the identity/root key. Reports involving transfer-envelope plaintext, weak key establishment, -clone handling, device-status staleness, sibling-device authority, root loss, or v1 downgrade are -security-sensitive. See +not the identity/root key. Reports involving transfer-envelope plaintext, weak key establishment, QR +capture or unintended persistence, clone handling, device-status staleness, sibling-device +authority, root loss, or v1 downgrade are security-sensitive. See [`docs/protocol/device-delegation-v2.md`](./docs/protocol/device-delegation-v2.md). Nexus provides pseudonymous cryptographic identity, not network anonymity. A report showing diff --git a/apps/wallet/package.json b/apps/wallet/package.json index d277e72..93118eb 100644 --- a/apps/wallet/package.json +++ b/apps/wallet/package.json @@ -16,8 +16,10 @@ "@nexus/verifier": "workspace:*", "@nexus/wallet-core": "workspace:*", "clsx": "^2.1.1", + "jsqr": "^1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.5.0" diff --git a/apps/wallet/src/components/DeviceManagement.tsx b/apps/wallet/src/components/DeviceManagement.tsx index 35bce30..52843db 100644 --- a/apps/wallet/src/components/DeviceManagement.tsx +++ b/apps/wallet/src/components/DeviceManagement.tsx @@ -1,4 +1,6 @@ -import { useState, type ChangeEvent, type FormEvent } from 'react'; +import { useCallback, useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'; +import jsQR from 'jsqr'; +import { QRCodeSVG } from 'qrcode.react'; import type { DeviceInstallResult } from '../lib/device-management'; import type { DeviceTransferEnvelopeV2, @@ -8,12 +10,14 @@ import type { } from '@nexus/wallet-core'; import { AlertTriangle, + Camera, Check, Copy, Download, FileKey2, KeyRound, Loader2, + QrCode, ShieldOff, Smartphone, Upload, @@ -23,7 +27,9 @@ import { decodeDeviceTransferKey, encodeDeviceTransferKey, MAX_DEVICE_TRANSFER_FILE_BYTES, + parseDeviceTransferQr, parseDeviceTransferBundleJson, + serializeDeviceTransferQr, serializeDeviceTransferBundle, } from '../lib/device-management'; import { ModalShell } from './ModalShell'; @@ -61,6 +67,7 @@ interface IssueDeviceModalProps { } export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModalProps) { + const [method, setMethod] = useState<'qr' | 'file'>('file'); const [label, setLabel] = useState(''); const [understood, setUnderstood] = useState(false); const [state, setState] = useState(idle); @@ -68,6 +75,7 @@ export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModa bundle: DeviceTransferEnvelopeV2; transferKey: string; deviceId: string; + qrPayload?: string; }>(); const [copied, setCopied] = useState(false); @@ -84,12 +92,23 @@ export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModa } finally { result.transferKey.fill(0); } + let qrPayload: string | undefined; + try { + qrPayload = serializeDeviceTransferQr(result.bundle, transferKey); + } catch (error) { + setMethod('file'); + setState({ + busy: false, + error: `${errorMessage(error)} The encrypted JSON transfer is still available.`, + }); + } setIssued({ bundle: result.bundle, transferKey, deviceId: result.authorization.payload.deviceId, + ...(qrPayload === undefined ? {} : { qrPayload }), }); - setState({ busy: false }); + if (qrPayload !== undefined) setState({ busy: false }); } catch (error) { setState({ busy: false, error: errorMessage(error) }); } @@ -98,6 +117,7 @@ export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModa const close = () => { setIssued(undefined); setCopied(false); + setState(idle); onClose(); }; @@ -123,6 +143,7 @@ export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModa > {issued === undefined ? (
void submit(event)} className="space-y-5"> +
The identity key is never exported. Nexus creates an independent device key and puts it - only inside an encrypted transfer file. + only inside the encrypted device transfer.
One installation intended
- Copies of the file and key can create indistinguishable clones of this device. Keep them - separate, install once, then remove both transfer copies. + {method === 'qr' + ? 'The QR contains everything needed to install this device key. Anyone who scans or photographs it can create an indistinguishable clone.' + : 'Copies of the file and key can create indistinguishable clones of this device. Keep them separate, install once, then remove both transfer copies.'}
{state.error !== undefined && } @@ -179,41 +203,76 @@ export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModa
Device authorized
- Download the encrypted file, then copy the key separately. The key is not in the file. + Choose one offline method to install the authorized device key. - -
- -
- - +
+ +
+ + +
+
-
+ )} {state.error !== undefined && } + + + ); +} + +interface DeviceTransferQrScannerProps { + onScan: (payload: string) => void; + scanned: boolean; + disabled: boolean; +} + +function DeviceTransferQrScanner({ onScan, scanned, disabled }: DeviceTransferQrScannerProps) { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const streamRef = useRef(undefined); + const frameRef = useRef(undefined); + const lastScanAtRef = useRef(0); + const lastPayloadRef = useRef(''); + const onScanRef = useRef(onScan); + const [starting, setStarting] = useState(false); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(); + onScanRef.current = onScan; + + const stopCamera = useCallback(() => { + if (frameRef.current !== undefined) cancelAnimationFrame(frameRef.current); + frameRef.current = undefined; + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = undefined; + if (videoRef.current !== null) videoRef.current.srcObject = null; + const canvas = canvasRef.current; + if (canvas !== null) { + canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height); + canvas.width = 1; + canvas.height = 1; + } + lastPayloadRef.current = ''; + setStarting(false); + setScanning(false); + }, []); + + const scanFrame = useCallback( + (time: number) => { + const video = videoRef.current; + const canvas = canvasRef.current; + if (video === null || canvas === null || streamRef.current === undefined) return; + + if ( + time - lastScanAtRef.current >= 120 && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && + video.videoWidth > 0 && + video.videoHeight > 0 + ) { + lastScanAtRef.current = time; + const scale = Math.min(1, 960 / Math.max(video.videoWidth, video.videoHeight)); + canvas.width = Math.max(1, Math.round(video.videoWidth * scale)); + canvas.height = Math.max(1, Math.round(video.videoHeight * scale)); + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (context !== null) { + context.drawImage(video, 0, 0, canvas.width, canvas.height); + const image = context.getImageData(0, 0, canvas.width, canvas.height); + const result = jsQR(image.data, image.width, image.height, { + inversionAttempts: 'attemptBoth', + }); + if (result !== null && result.data !== lastPayloadRef.current) { + lastPayloadRef.current = result.data; + try { + setError(undefined); + onScanRef.current(result.data); + stopCamera(); + return; + } catch (scanError) { + setError(errorMessage(scanError)); + } + } + } + } + frameRef.current = requestAnimationFrame(scanFrame); + }, + [stopCamera], + ); + + const startCamera = async () => { + if (starting || scanning) return; + setStarting(true); + setError(undefined); + lastPayloadRef.current = ''; + if (!globalThis.isSecureContext || navigator.mediaDevices?.getUserMedia === undefined) { + setError('Camera scanning requires HTTPS or localhost and a browser with camera access.'); + setStarting(false); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { facingMode: { ideal: 'environment' } }, + }); + streamRef.current = stream; + const video = videoRef.current; + if (video === null) { + stopCamera(); + return; + } + video.srcObject = stream; + await video.play(); + setStarting(false); + setScanning(true); + frameRef.current = requestAnimationFrame(scanFrame); + } catch (cameraError) { + stopCamera(); + setError( + cameraError instanceof DOMException && cameraError.name === 'NotAllowedError' + ? 'Camera access was denied. Allow camera access, then try again.' + : errorMessage(cameraError), + ); + } + }; + + useEffect(() => { + return stopCamera; + }, [stopCamera]); + + return ( +
+
+
+ {!scanned && ( + + )} + {error !== undefined && } +
+ ); +} + interface RevokeDeviceModalProps { role: 'root' | 'device'; deviceLabel?: string; @@ -451,3 +733,7 @@ const primaryButton = 'flex flex-1 items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-indigo-400'; const compactButton = 'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50'; +const methodButton = + 'flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50'; +const selectedMethodButton = + 'flex items-center justify-center gap-2 rounded-xl border border-indigo-500 bg-indigo-50 px-3 py-2.5 text-sm font-semibold text-indigo-700 ring-1 ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-50'; diff --git a/apps/wallet/src/lib/device-management.test.ts b/apps/wallet/src/lib/device-management.test.ts index 7013816..7604080 100644 --- a/apps/wallet/src/lib/device-management.test.ts +++ b/apps/wallet/src/lib/device-management.test.ts @@ -11,7 +11,9 @@ import { deviceManagementCapabilities, encodeDeviceTransferKey, installAndActivateDevice, + parseDeviceTransferQr, serializeDeviceTransferBundle, + serializeDeviceTransferQr, } from './device-management'; function identity(overrides: Partial = {}): LocalIdentitySummary { @@ -38,6 +40,15 @@ const bundle: DeviceTransferEnvelopeV2 = { ciphertext: 'ciphertext', }; +const qrBundle: DeviceTransferEnvelopeV2 = { + protocol: 'nexus.device-transfer.v2', + suite: 'NX-HKDF-SHA256-AES256GCM-v2', + bundleId: encodeDeviceTransferKey(new Uint8Array(32).fill(1)), + salt: encodeDeviceTransferKey(new Uint8Array(32).fill(2)), + iv: 'AwMDAwMDAwMDAwMD', + ciphertext: 'BAQEBAQEBAQEBAQEBAQEBA', +}; + const imported = { localId: 'device-local', subject: 'nx1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', @@ -103,6 +114,28 @@ describe('v2 wallet device management', () => { ]); }); + it('round-trips a complete offline QR bearer credential', () => { + const key = encodeDeviceTransferKey(new Uint8Array(32).fill(5)); + const payload = serializeDeviceTransferQr(qrBundle, key); + + expect(payload).toContain(key); + expect(payload).not.toContain('https://'); + expect(parseDeviceTransferQr(payload)).toEqual({ bundle: qrBundle, transferKey: key }); + }); + + it('strictly rejects non-Nexus, malformed, and oversized QR payloads', () => { + expect(() => parseDeviceTransferQr('https://example.com/device-transfer')).toThrow( + 'not a Nexus device transfer', + ); + const key = encodeDeviceTransferKey(new Uint8Array(32).fill(5)); + const payload = serializeDeviceTransferQr(qrBundle, key); + expect(() => parseDeviceTransferQr(`${payload}.extra`)).toThrow('malformed'); + expect(() => parseDeviceTransferQr(`${payload.slice(0, -1)}!`)).toThrow('43-character'); + expect(() => + serializeDeviceTransferQr({ ...qrBundle, ciphertext: 'A'.repeat(2_100) }, key), + ).toThrow('too large for one QR code'); + }); + it('retains the installed device for retry when activation fails and wipes key bytes', async () => { const transferKey = new Uint8Array(32).fill(7); const importDeviceTransfer = vi.fn(() => Promise.resolve(imported)); diff --git a/apps/wallet/src/lib/device-management.ts b/apps/wallet/src/lib/device-management.ts index 3dfebfb..95bce66 100644 --- a/apps/wallet/src/lib/device-management.ts +++ b/apps/wallet/src/lib/device-management.ts @@ -5,6 +5,8 @@ import type { } from '@nexus/wallet-core'; export const MAX_DEVICE_TRANSFER_FILE_BYTES = 96 * 1024; +export const MAX_DEVICE_TRANSFER_QR_BYTES = 2_200; +export const DEVICE_TRANSFER_QR_PREFIX = 'nexus-device-transfer:v2:'; export interface DeviceManagementCapabilities { issueDevice: boolean; @@ -72,6 +74,35 @@ export function decodeDeviceTransferKey(value: string): Uint8Array { return bytes; } +function requireCanonicalBase64Url( + value: string, + label: string, + expectedBytes?: number, + minimumBytes = 1, +): void { + if (!/^[A-Za-z0-9_-]+$/u.test(value) || value.length % 4 === 1) { + throw new Error(`${label} is not valid base64url.`); + } + let bytes: Uint8Array | undefined; + try { + const padding = '='.repeat((4 - (value.length % 4)) % 4); + const binary = atob(`${value.replaceAll('-', '+').replaceAll('_', '/')}${padding}`); + bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + if ( + (expectedBytes !== undefined && bytes.byteLength !== expectedBytes) || + bytes.byteLength < minimumBytes || + encodeBase64Url(bytes) !== value + ) { + throw new Error(`${label} has an invalid length or encoding.`); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith(label)) throw error; + throw new Error(`${label} is not valid base64url.`, { cause: error }); + } finally { + bytes?.fill(0); + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -120,6 +151,76 @@ export function serializeDeviceTransferBundle(bundle: DeviceTransferEnvelopeV2): return serialized; } +export interface DeviceTransferQrV2 { + bundle: DeviceTransferEnvelopeV2; + /** Canonical base64url. Decode only immediately before import, then wipe the resulting bytes. */ + transferKey: string; +} + +/** + * Produces a compact, offline-only QR payload. Unlike the JSON method, the QR is a complete bearer + * credential because it contains both the encrypted envelope and its transfer key. + */ +export function serializeDeviceTransferQr( + bundle: DeviceTransferEnvelopeV2, + transferKey: string, +): string { + const validatedBundle = parseDeviceTransferBundleJson(JSON.stringify(bundle)); + requireCanonicalBase64Url(validatedBundle.bundleId, 'The QR bundle ID', 32); + requireCanonicalBase64Url(validatedBundle.salt, 'The QR salt', 32); + requireCanonicalBase64Url(validatedBundle.iv, 'The QR IV', 12); + requireCanonicalBase64Url(validatedBundle.ciphertext, 'The QR ciphertext', undefined, 16); + const keyBytes = decodeDeviceTransferKey(transferKey); + keyBytes.fill(0); + + const payload = `${DEVICE_TRANSFER_QR_PREFIX}${validatedBundle.bundleId}.${validatedBundle.salt}.${validatedBundle.iv}.${validatedBundle.ciphertext}.${transferKey}`; + if (new TextEncoder().encode(payload).byteLength > MAX_DEVICE_TRANSFER_QR_BYTES) { + throw new Error('This device transfer is too large for one QR code. Use the JSON method.'); + } + return payload; +} + +/** Strictly decodes the complete bearer credential produced by serializeDeviceTransferQr. */ +export function parseDeviceTransferQr(payload: string): DeviceTransferQrV2 { + if (new TextEncoder().encode(payload).byteLength > MAX_DEVICE_TRANSFER_QR_BYTES) { + throw new Error('The scanned QR code is too large to be a Nexus device transfer.'); + } + if (!payload.startsWith(DEVICE_TRANSFER_QR_PREFIX)) { + throw new Error('This is not a Nexus device transfer QR code.'); + } + const components = payload.slice(DEVICE_TRANSFER_QR_PREFIX.length).split('.'); + if (components.length !== 5) { + throw new Error('The Nexus device transfer QR code is malformed.'); + } + const [bundleId, salt, iv, ciphertext, transferKey] = components; + if ( + bundleId === undefined || + salt === undefined || + iv === undefined || + ciphertext === undefined || + transferKey === undefined + ) { + throw new Error('The Nexus device transfer QR code is incomplete.'); + } + requireCanonicalBase64Url(bundleId, 'The QR bundle ID', 32); + requireCanonicalBase64Url(salt, 'The QR salt', 32); + requireCanonicalBase64Url(iv, 'The QR IV', 12); + requireCanonicalBase64Url(ciphertext, 'The QR ciphertext', undefined, 16); + const keyBytes = decodeDeviceTransferKey(transferKey); + keyBytes.fill(0); + return { + bundle: { + protocol: 'nexus.device-transfer.v2', + suite: 'NX-HKDF-SHA256-AES256GCM-v2', + bundleId, + salt, + iv, + ciphertext, + }, + transferKey, + }; +} + export interface DeviceInstallOperations { importDeviceTransfer( bundle: DeviceTransferEnvelopeV2, diff --git a/docs/protocol/device-delegation-v2.md b/docs/protocol/device-delegation-v2.md index c630db6..f0e0d68 100644 --- a/docs/protocol/device-delegation-v2.md +++ b/docs/protocol/device-delegation-v2.md @@ -441,8 +441,30 @@ interface DeviceTransferEnvelopeV2 { It generates a separate uniformly random 32-byte transfer key, expands the content key with HKDF-SHA-256 using `NEXUS-DEVICE-TRANSFER\0v2\0` as `info`, and authenticates the canonical envelope -header as AES-GCM additional data. The transfer key is delivered over a channel separate from the -bundle and MUST NOT be embedded in or stored beside it. +header as AES-GCM additional data. + +The reference wallet offers two offline transports: + +- **JSON bundle:** the transfer key MUST be delivered over a channel separate from the downloaded + bundle and MUST NOT be embedded in or stored beside that file. +- **Direct-display QR:** the root wallet MAY render one QR that contains both the encrypted envelope + and transfer key for a nearby target wallet to scan. The QR is therefore a complete bearer + credential: encryption does not protect it from a camera, screenshot, shoulder surfer, or copied + display. The wallet MUST generate and decode it locally without a QR web service, URL shortener, + analytics request, or network fetch; MUST NOT automatically copy, download, or persist it; and + MUST warn that any capture can install an indistinguishable clone. Closing the transfer flow MUST + discard the in-memory representation on a best-effort basis. + +The compact reference QR payload is ASCII and versioned independently from the encrypted envelope: + +```text +nexus-device-transfer:v2:.... +``` + +Every component is canonical unpadded base64url; `bundleId`, `salt`, `iv`, and `transferKey` decode +to 32, 32, 12, and 32 bytes respectively. The reference profile caps the complete payload at 2,200 +bytes so it fits one QR symbol at error-correction level M. A larger transfer MUST fall back to the +JSON method instead of splitting secrets across unversioned animated or multi-part QR codes. The destination-generated alternative is safer: the destination creates a non-extractable device key and sends only its public enrollment request to the offline root. The root signs an authorization, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d38455c..f503c00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,12 +129,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 lucide-react: specifier: ^0.546.0 version: 0.546.0(react@19.2.8) motion: specifier: ^12.23.24 version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.8) react: specifier: ^19.0.0 version: 19.2.8 @@ -1891,6 +1897,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2115,6 +2124,11 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -3781,6 +3795,8 @@ snapshots: json5@2.2.3: {} + jsqr@1.4.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3962,6 +3978,10 @@ snapshots: pure-rand@8.4.2: {} + qrcode.react@4.2.0(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8