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
28 changes: 28 additions & 0 deletions apps/wallet/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function App() {
const [retryingRegistrationId, setRetryingRegistrationId] = useState<string>();
const [clearingHistoryId, setClearingHistoryId] = useState<string>();
const [deviceActionId, setDeviceActionId] = useState<string>();
const [refreshingDeviceId, setRefreshingDeviceId] = useState<string>();
const pendingRef = useRef<PendingProofRequest | undefined>(undefined);

const refresh = useCallback(async () => {
Expand Down Expand Up @@ -282,6 +283,31 @@ function App() {
}
};

const refreshDeviceStatus = async (
identity: LocalIdentitySummary,
deviceId?: LocalIdentitySummary['issuedDevices'][number]['deviceId'],
) => {
const targetDeviceId = deviceId ?? identity.device?.deviceId;
if (targetDeviceId === undefined || refreshingDeviceId !== undefined) return;
setRefreshingDeviceId(targetDeviceId);
try {
const status = await walletAdapter.refreshDeviceStatus(identity.localId, deviceId);
await refresh();
setNotice({
kind: status.deviceState === 'active' ? 'success' : 'error',
message: `Registry device status: ${status.deviceState}.`,
});
} catch (error) {
setNotice({
kind: 'error',
message:
error instanceof Error ? error.message : 'The device status could not be refreshed.',
});
} finally {
setRefreshingDeviceId(undefined);
}
};

const approveProof = async (
localId: string,
rememberScope: boolean,
Expand Down Expand Up @@ -506,6 +532,8 @@ function App() {
onRootRevokeDevice={(device) =>
setFlow({ type: 'root-revoke-device', identity: selectedIdentity, device })
}
onRefreshDevice={(deviceId) => refreshDeviceStatus(selectedIdentity, deviceId)}
{...(refreshingDeviceId === undefined ? {} : { refreshingDeviceId })}
deviceActionBusy={deviceActionId !== undefined}
/>
</div>
Expand Down
97 changes: 83 additions & 14 deletions apps/wallet/src/components/IdentityDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ interface IdentityDetailProps {
onActivateDevice: () => Promise<void>;
onSelfRevokeDevice: () => void;
onRootRevokeDevice: (device: LocalIdentitySummary['issuedDevices'][number]) => void;
onRefreshDevice: (
deviceId?: LocalIdentitySummary['issuedDevices'][number]['deviceId'],
) => Promise<void>;
refreshingDeviceId?: string;
deviceActionBusy: boolean;
}

Expand All @@ -60,6 +64,8 @@ export function IdentityDetail({
onActivateDevice,
onSelfRevokeDevice,
onRootRevokeDevice,
onRefreshDevice,
refreshingDeviceId,
deviceActionBusy,
}: IdentityDetailProps) {
const active = identity.localState === 'active';
Expand All @@ -71,6 +77,7 @@ export function IdentityDetail({
const [renameError, setRenameError] = useState<string>();
const [now, setNow] = useState(() => Math.floor(Date.now() / 1_000));
const deviceCapabilities = deviceManagementCapabilities(identity, now);
const installedRegistryState = identity.device?.registryState;

useEffect(() => {
if (identity.device?.localState !== 'pending-activation') return;
Expand Down Expand Up @@ -298,11 +305,17 @@ export function IdentityDetail({
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900">
{identity.device.localState === 'pending-activation'
? 'Pending activation'
: identity.device.localState === 'active'
? 'Active delegated key'
: 'Revoked delegated key'}
{installedRegistryState === 'revoked'
? 'Revoked delegated key'
: installedRegistryState === 'expired'
? 'Expired delegated key'
: installedRegistryState === 'unknown'
? 'Not active at registry'
: identity.device.localState === 'pending-activation'
? 'Pending activation'
: identity.device.localState === 'active'
? 'Active delegated key'
: 'Revoked delegated key'}
</p>
<code
className="mt-1 block truncate text-xs text-slate-400"
Expand All @@ -315,7 +328,28 @@ export function IdentityDetail({
{new Date(identity.device.expiresAt * 1_000).toLocaleString()}. This key
cannot add or remove other devices.
</p>
{identity.device.statusCheckedAt !== undefined && (
<p className="mt-1 text-xs text-slate-400">
Registry checked{' '}
{new Date(identity.device.statusCheckedAt * 1_000).toLocaleString()}
</p>
)}
</div>
<button
type="button"
onClick={() => void onRefreshDevice()}
disabled={refreshingDeviceId !== undefined || deviceActionBusy}
className={smallSecondaryButton}
>
{refreshingDeviceId === identity.device.deviceId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{refreshingDeviceId === identity.device.deviceId
? 'Checking…'
: 'Refresh status'}
</button>
{deviceCapabilities.activateDevice && (
<button
type="button"
Expand Down Expand Up @@ -373,12 +407,22 @@ export function IdentityDetail({
</p>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${
device.localState === 'revoked'
device.localState === 'revoked' || device.registryState === 'revoked'
? 'bg-rose-100 text-rose-800'
: 'bg-emerald-100 text-emerald-800'
: device.registryState === 'active'
? 'bg-emerald-100 text-emerald-800'
: 'bg-amber-100 text-amber-800'
}`}
>
{device.localState === 'revoked' ? 'Revoked' : 'Issued'}
{device.localState === 'revoked' || device.registryState === 'revoked'
? 'Revoked'
: device.registryState === 'active'
? 'Active'
: device.registryState === 'expired'
? 'Expired'
: device.registryState === 'unknown'
? 'Not activated'
: 'Not checked'}
</span>
</div>
<code
Expand All @@ -390,17 +434,40 @@ export function IdentityDetail({
<p className="mt-1 text-xs text-slate-500">
Expires {new Date(device.expiresAt * 1_000).toLocaleString()}
</p>
{device.statusCheckedAt !== undefined && (
<p className="mt-1 text-xs text-slate-400">
Registry checked{' '}
{new Date(device.statusCheckedAt * 1_000).toLocaleString()}
</p>
)}
</div>
{device.localState !== 'revoked' && deviceCapabilities.rootRevokeDevice && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onRootRevokeDevice(device)}
disabled={deviceActionBusy}
className={smallDangerButton}
onClick={() => void onRefreshDevice(device.deviceId)}
disabled={refreshingDeviceId !== undefined || deviceActionBusy}
className={smallSecondaryButton}
>
<ShieldOff className="h-3.5 w-3.5" /> Remove
{refreshingDeviceId === device.deviceId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{refreshingDeviceId === device.deviceId ? 'Checking…' : 'Refresh'}
</button>
)}
{device.localState !== 'revoked' &&
device.registryState !== 'revoked' &&
deviceCapabilities.rootRevokeDevice && (
<button
type="button"
onClick={() => onRootRevokeDevice(device)}
disabled={deviceActionBusy || refreshingDeviceId !== undefined}
className={smallDangerButton}
>
<ShieldOff className="h-3.5 w-3.5" /> Remove
</button>
)}
</div>
</div>
</li>
))}
Expand Down Expand Up @@ -564,3 +631,5 @@ const smallPrimaryButton =
'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-indigo-300';
const smallDangerButton =
'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-700 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50';
const smallSecondaryButton =
'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50';
2 changes: 2 additions & 0 deletions apps/wallet/src/lib/device-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export function deviceManagementCapabilities(
activateDevice:
locallyActive &&
identity.device.localState === 'pending-activation' &&
identity.device.registryState !== 'revoked' &&
identity.device.registryState !== 'expired' &&
now < identity.device.activationDeadline &&
now < identity.device.expiresAt,
selfRevokeDevice: locallyActive && identity.device.localState !== 'revoked',
Expand Down
30 changes: 29 additions & 1 deletion apps/wallet/src/lib/wallet-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {
registryReceiptV1Schema,
registryStatusV1Schema,
deviceRegistryStatusV2Schema,
serviceKeySetSchema,
type ContinuityLinkV1,
type DeviceActivationRequestV2,
type DeviceRegistryReceiptV2,
type DeviceRegistryStatusV2,
type DeviceRootRevokeRequestV2,
type DeviceSelfRevokeRequestV2,
type DeviceStatusRequestV2,
type OwnershipProofV1,
type OwnershipProofV2,
type ProofRequest,
Expand All @@ -15,7 +18,11 @@ import {
type RevokeBySignatureRequestV1,
type ServiceKeySet,
} from '@nexus/protocol';
import { verifyDeviceRegistryReceipt, verifyRegistryReceipt } from '@nexus/verifier';
import {
verifyDeviceRegistryReceipt,
verifyDeviceStatusStatement,
verifyRegistryReceipt,
} from '@nexus/verifier';
import {
IndexedDbIdentityStore,
WalletCore,
Expand Down Expand Up @@ -163,6 +170,10 @@ class HttpRegistryClient implements RegistryClient {
return this.#deviceMutation('/v2/device/revoke-root', request);
}

public async getDeviceStatus(request: DeviceStatusRequestV2): Promise<DeviceRegistryStatusV2> {
return deviceRegistryStatusV2Schema.parse(await postRegistry('/v2/device/status', request));
}

async #deviceMutation(path: string, request: unknown): Promise<DeviceRegistryReceiptV2> {
const value = await postRegistry(path, request);
if (!isRecord(value)) throw new Error('The device registry response is malformed.');
Expand All @@ -179,6 +190,16 @@ const walletCore = new WalletCore({
verifyRegistryReceipt(receipt, await loadServiceKeyset()),
verifyDeviceRegistryReceipt: async (receipt) =>
verifyDeviceRegistryReceipt(receipt, await loadServiceKeyset()),
verifyDeviceRegistryStatus: async (status, expected) => {
const parsed = deviceRegistryStatusV2Schema.parse(status);
await verifyDeviceStatusStatement(
parsed.statusStatement,
await loadServiceKeyset(),
Math.floor(Date.now() / 1_000),
expected,
);
return parsed;
},
});

export interface CreateIdentityInput {
Expand Down Expand Up @@ -268,6 +289,13 @@ export const walletAdapter = {
return walletCore.revokeDeviceRoot(rootLocalId, deviceId, { reasonCode: 'replaced' });
},

refreshDeviceStatus(
localId: string,
deviceId?: LocalIdentitySummary['issuedDevices'][number]['deviceId'],
): Promise<DeviceRegistryStatusV2> {
return walletCore.refreshDeviceStatus(localId, deviceId);
},

dispose(localId: string): Promise<RegistryReceiptV1> {
return walletCore.dispose(localId, {
method: 'signature',
Expand Down
8 changes: 8 additions & 0 deletions docs/protocol/device-delegation-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,14 @@ reports at least the subject, genesis hash, identity state and sequence, device-
device and authorization IDs, effective device state, authorization expiry, statement validity, and
service signing `kid`.

The wallet permits the holder to poll this exact status tuple manually for an installed device or
for a device in the root wallet's local authorization catalog. It MUST validate the response schema,
service signature, freshness, and exact subject, genesis, device, and authorization binding before
recording the observation. An observed revoked, expired, or unknown state disables device proofs
locally. Polling alone MUST NOT delete a device key or mark receipt-gated local revocation complete;
key deletion still requires a verified terminal registry receipt. The reference wallet records the
last checked time and does not require a background service or push channel.

Recommended additive endpoints are:

```text
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type {
CreatedLocalIdentity,
CreateIdentityOptions,
DeviceRegistryReceiptVerifier,
DeviceRegistryStatusExpectation,
DeviceRegistryStatusVerifier,
DeviceRequestOptions,
DeviceRevocationOptions,
DeviceTransferEnvelopeV2,
Expand Down
Loading