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
31 changes: 28 additions & 3 deletions apps/wallet/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
installAndActivateDevice,
type DeviceInstallResult,
} from './lib/device-management';
import { createIdentityWithOptionalLocalDevice } from './lib/create-identity';
import {
walletAdapter,
type CreateIdentityInput,
Expand Down Expand Up @@ -136,11 +137,35 @@ function App() {
const scopedCount = identities.filter((identity) => identity.localScopes.length > 0).length;

const createIdentity = async (input: CreateIdentityInput) => {
const created = await walletAdapter.createIdentity(input);
const created = await createIdentityWithOptionalLocalDevice(walletAdapter, input);
await refresh();
setSelectedId(created.localId);
setSelectedId(created.device?.localId ?? created.root.localId);
setFlow(undefined);
setNotice({ kind: 'success', message: 'Independent identity created and registered.' });
if (created.deviceState === 'active' && created.deviceError === undefined) {
setNotice({
kind: 'success',
message: 'Identity created. Its separate device key is active on this device.',
});
return;
}
if (created.device !== undefined) {
setNotice({
kind: 'error',
message:
created.deviceState === 'pending-activation'
? `Identity created and the device key was installed safely, but activation is pending. Open it and retry. ${created.deviceError ?? ''}`.trim()
: `Identity and device key created. ${created.deviceError ?? 'The local device setup needs attention.'}`,
});
return;
}
if (created.deviceError !== undefined) {
setNotice({
kind: 'error',
message: `Identity created and registered, but its device key could not be installed. You can add one from the identity details. ${created.deviceError}`,
});
return;
}
setNotice({ kind: 'success', message: 'Independent root identity created and registered.' });
};

const retryRegistration = async (identity: LocalIdentitySummary) => {
Expand Down
25 changes: 25 additions & 0 deletions apps/wallet/src/components/flows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Link2,
Loader2,
ShieldAlert,
Smartphone,
Sparkles,
Trash2,
} from 'lucide-react';
Expand All @@ -35,6 +36,7 @@ interface CreateIdentityModalProps {

export function CreateIdentityModal({ onClose, onCreate }: CreateIdentityModalProps) {
const [label, setLabel] = useState('');
const [withDeviceKey, setWithDeviceKey] = useState(true);
const [withAgreementKey, setWithAgreementKey] = useState(false);
const [state, setState] = useState<AsyncFormState>(idle);

Expand All @@ -45,6 +47,7 @@ export function CreateIdentityModal({ onClose, onCreate }: CreateIdentityModalPr
const normalized = label.trim();
await onCreate({
...(normalized === '' ? {} : { label: normalized }),
withDeviceKey,
withAgreementKey,
});
} catch (error) {
Expand Down Expand Up @@ -79,6 +82,28 @@ export function CreateIdentityModal({ onClose, onCreate }: CreateIdentityModalPr
</p>
</div>

<label className="flex items-start gap-3 rounded-xl border border-indigo-200 bg-indigo-50/60 p-4 transition-colors hover:border-indigo-300">
<input
type="checkbox"
checked={withDeviceKey}
onChange={(event) => setWithDeviceKey(event.target.checked)}
className="mt-1 accent-indigo-600"
/>
<span>
<span className="flex items-center gap-2 text-sm font-medium text-slate-900">
<Smartphone className="h-4 w-4 text-indigo-600" aria-hidden="true" />
Add a device key on this device
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-indigo-700">
Recommended
</span>
</span>
<span className="mt-1 block text-xs leading-relaxed text-slate-600">
Creates and activates a separate key for everyday identity proofs. It represents the
same identity and can be revoked without revoking your root key.
</span>
</span>
</label>

<label className="flex items-start gap-3 rounded-xl border border-slate-200 p-4 transition-colors hover:border-indigo-300">
<input
type="checkbox"
Expand Down
112 changes: 112 additions & 0 deletions apps/wallet/src/lib/create-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { CreatedLocalIdentity, ImportedDeviceV2 } from '@nexus/wallet-core';
import {
deviceAuthorizationV2Schema,
identityGenesisV1Schema,
nexusDeviceAuthorizationIdV2Schema,
nexusDeviceIdV2Schema,
nexusSubjectSchema,
} from '@nexus/protocol';
import { describe, expect, it, vi } from 'vitest';

import {
createIdentityWithOptionalLocalDevice,
type CreateIdentityOperations,
} from './create-identity';

const root: CreatedLocalIdentity = {
localId: 'root-local',
subject: nexusSubjectSchema.parse('nx1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'),
genesis: identityGenesisV1Schema.parse({
protocol: 'nexus.identity.v1',
suite: 'NX-25519-SHA256-JCS-v1',
signingKey: {
alg: 'Ed25519',
publicKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
},
revocationCommitment: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
}),
};

const imported: ImportedDeviceV2 = {
localId: 'device-local',
subject: root.subject,
deviceId: nexusDeviceIdV2Schema.parse('nxd2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'),
authorizationId: nexusDeviceAuthorizationIdV2Schema.parse(
'nxa2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
),
authorization: deviceAuthorizationV2Schema.parse({
payload: {
protocol: 'nexus.device-authorization.v2',
subject: root.subject,
genesisHash: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
deviceId: 'nxd2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
signingKey: {
alg: 'Ed25519',
publicKey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA',
},
authorizationNonce: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
validFrom: 1,
activationDeadline: 2,
expiresAt: 3,
},
rootSignature:
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
}),
};

function operations(overrides: Partial<CreateIdentityOperations> = {}): CreateIdentityOperations {
return {
createIdentity: vi.fn(() => Promise.resolve(root)),
provisionDeviceOnThisWallet: vi.fn(() => Promise.resolve(imported)),
activateDevice: vi.fn(() => Promise.resolve()),
...overrides,
};
}

describe('new identity local-device provisioning', () => {
it('creates, installs, activates, and labels a device by default', async () => {
const api = operations();
const result = await createIdentityWithOptionalLocalDevice(api, { label: 'Campus forum' });

expect(api.provisionDeviceOnThisWallet).toHaveBeenCalledWith(root.localId, {
label: 'Campus forum · this device',
});
expect(api.activateDevice).toHaveBeenCalledWith(imported.localId);
expect(result).toMatchObject({ root, device: imported, deviceState: 'active' });
});

it('preserves the exact root-only flow when the option is disabled', async () => {
const api = operations();
const result = await createIdentityWithOptionalLocalDevice(api, { withDeviceKey: false });

expect(result).toEqual({ root });
expect(api.provisionDeviceOnThisWallet).not.toHaveBeenCalled();
expect(api.activateDevice).not.toHaveBeenCalled();
});

it('retains and selects a pending device when activation is unavailable', async () => {
const api = operations({
activateDevice: vi.fn(() => Promise.reject(new Error('registry unavailable'))),
});
const result = await createIdentityWithOptionalLocalDevice(api, {});

expect(result).toMatchObject({
root,
device: imported,
deviceState: 'pending-activation',
deviceError: 'registry unavailable',
});
expect(api.provisionDeviceOnThisWallet).toHaveBeenCalledWith(root.localId, {
label: 'This device',
});
});

it('reports partial success when same-wallet provisioning fails', async () => {
const api = operations({
provisionDeviceOnThisWallet: vi.fn(() => Promise.reject(new Error('storage unavailable'))),
});
const result = await createIdentityWithOptionalLocalDevice(api, {});

expect(result).toEqual({ root, deviceError: 'storage unavailable' });
});
});
63 changes: 63 additions & 0 deletions apps/wallet/src/lib/create-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {
CreatedLocalIdentity,
ImportedDeviceV2,
IssueDeviceTransferOptions,
} from '@nexus/wallet-core';

import type { DeviceInstallResult } from './device-management';
import type { CreateIdentityInput } from './wallet-adapter';

export interface CreatedIdentityWithLocalDevice {
root: CreatedLocalIdentity;
device?: ImportedDeviceV2;
deviceState?: DeviceInstallResult['state'];
deviceError?: string;
}

export interface CreateIdentityOperations {
createIdentity: (input: CreateIdentityInput) => Promise<CreatedLocalIdentity>;
provisionDeviceOnThisWallet: (
rootLocalId: string,
options?: IssueDeviceTransferOptions,
) => Promise<ImportedDeviceV2>;
activateDevice: (localId: string) => Promise<unknown>;
}

function deviceNickname(rootNickname: string | undefined): string {
return rootNickname === undefined ? 'This device' : `${rootNickname} · this device`;
}

function describeError(error: unknown): string {
return error instanceof Error ? error.message : 'The device key could not be installed.';
}

/**
* Creates the root first, then optionally provisions a separate device key through the existing
* authenticated transfer pipeline. Once the root exists, device failures are returned as partial
* success so the UI never invites the user to create a duplicate root identity.
*/
export async function createIdentityWithOptionalLocalDevice(
operations: CreateIdentityOperations,
input: CreateIdentityInput,
): Promise<CreatedIdentityWithLocalDevice> {
const root = await operations.createIdentity(input);
if (input.withDeviceKey === false) return { root };

try {
const label = deviceNickname(input.label);
const device = await operations.provisionDeviceOnThisWallet(root.localId, { label });
try {
await operations.activateDevice(device.localId);
} catch (error) {
return {
root,
device,
deviceState: 'pending-activation',
deviceError: describeError(error),
};
}
return { root, device, deviceState: 'active' };
} catch (error) {
return { root, deviceError: describeError(error) };
}
}
13 changes: 11 additions & 2 deletions apps/wallet/src/lib/wallet-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,14 @@ const walletCore = new WalletCore({

export interface CreateIdentityInput {
label?: string;
/** App-level convenience: provision a separate v2 device after the root is registered. */
withDeviceKey?: boolean;
withAgreementKey?: boolean;
}

export interface RotateIdentityInput extends CreateIdentityInput {
export type RotateIdentityInput = Omit<CreateIdentityInput, 'withDeviceKey'> & {
revokeOld: boolean;
}
};

export const walletAdapter = {
async listIdentities(): Promise<LocalIdentitySummary[]> {
Expand Down Expand Up @@ -268,6 +270,13 @@ export const walletAdapter = {
return walletCore.issueDeviceTransfer(rootLocalId, options);
},

provisionDeviceOnThisWallet(
rootLocalId: string,
options: IssueDeviceTransferOptions = {},
): Promise<ImportedDeviceV2> {
return walletCore.provisionDeviceOnThisWallet(rootLocalId, options);
},

importDeviceTransfer(
bundle: DeviceTransferEnvelopeV2,
transferKey: Uint8Array,
Expand Down
5 changes: 5 additions & 0 deletions packages/wallet-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ export interface WalletCoreApi {
rootLocalId: string,
options?: IssueDeviceTransferOptions,
): Promise<IssuedDeviceTransferV2>;
/** Provisions a device into this wallet without exposing transfer material to the caller. */
provisionDeviceOnThisWallet(
rootLocalId: string,
options?: IssueDeviceTransferOptions,
): Promise<ImportedDeviceV2>;
importDeviceTransfer(
bundle: DeviceTransferEnvelopeV2,
transferKey: Uint8Array,
Expand Down
36 changes: 36 additions & 0 deletions packages/wallet-core/src/wallet-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,41 @@ export class WalletCore implements WalletCoreApi {
public async importDeviceTransfer(
bundle: DeviceTransferEnvelopeV2,
transferKey: Uint8Array,
): Promise<ImportedDeviceV2> {
return this.#importDeviceTransfer(bundle, transferKey);
}

/**
* Provisions a separate device key into this wallet while keeping the encrypted transfer and its
* key behind the WalletCore boundary. Registry activation remains a separate retryable step.
*/
public async provisionDeviceOnThisWallet(
rootLocalId: string,
options: IssueDeviceTransferOptions = {},
): Promise<ImportedDeviceV2> {
if (
getManagedKeyVaultCapabilities(this.#keyVault) === undefined ||
this.#identityStore.update === undefined ||
this.#identityStore.appendIssuedDevice === undefined ||
this.#identityStore.reserveDeviceRecord === undefined
) {
throw new WalletCoreError(
'INVALID_REQUEST',
'This wallet does not support crash-safe same-device provisioning.',
);
}
const issued = await this.issueDeviceTransfer(rootLocalId, options);
try {
return await this.#importDeviceTransfer(issued.bundle, issued.transferKey, options.label);
} finally {
issued.transferKey.fill(0);
}
}

async #importDeviceTransfer(
bundle: DeviceTransferEnvelopeV2,
transferKey: Uint8Array,
localLabel?: string,
): Promise<ImportedDeviceV2> {
const managedKeyVault = getManagedKeyVaultCapabilities(this.#keyVault);
if (managedKeyVault === undefined) {
Expand Down Expand Up @@ -888,6 +923,7 @@ export class WalletCore implements WalletCoreApi {
localId,
subject,
genesis,
...(localLabel === undefined ? {} : { label: localLabel }),
localScopes: [],
authorizationHistory: [],
localState: 'active',
Expand Down
Loading