Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
452 changes: 369 additions & 83 deletions apps/wallet/src/components/DeviceManagement.tsx

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions apps/wallet/src/lib/device-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
deviceManagementCapabilities,
encodeDeviceTransferKey,
installAndActivateDevice,
parseDeviceTransferQr,
serializeDeviceTransferBundle,
serializeDeviceTransferQr,
} from './device-management';

function identity(overrides: Partial<LocalIdentitySummary> = {}): LocalIdentitySummary {
Expand All @@ -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',
Expand Down Expand Up @@ -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));
Expand Down
101 changes: 101 additions & 0 deletions apps/wallet/src/lib/device-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 24 additions & 2 deletions docs/protocol/device-delegation-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<bundleId>.<salt>.<iv>.<ciphertext>.<transferKey>
```

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,
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.