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
24 changes: 18 additions & 6 deletions apps/reference-rp/src/backend/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ export class AuthoritativeRegistryLifecycleProvider
*/
export class LocalLifecycleAuthority implements LifecycleProvider, DeviceLifecycleProvider {
readonly #states = new Map<NexusSubject, AuthoritativeLifecycleState>();
readonly #deviceStates = new Map<string, AuthoritativeDeviceLifecycleState>();
readonly #deviceStates = new Map<
string,
Exclude<AuthoritativeDeviceLifecycleState, { state: 'not-found' }>
>();

public constructor(private readonly now: () => number = () => Math.floor(Date.now() / 1000)) {}

Expand Down Expand Up @@ -220,20 +223,29 @@ export class LocalLifecycleAuthority implements LifecycleProvider, DeviceLifecyc
}

public getAuthoritativeDeviceStatus(
_subject: NexusSubject,
subject: NexusSubject,
deviceId: NexusDeviceIdV2,
authorizationId: NexusDeviceAuthorizationIdV2,
): Promise<AuthoritativeDeviceLifecycleState> {
return Promise.resolve(
this.#deviceStates.get(deviceId) ?? {
const device =
this.#deviceStates.get(deviceId) ??
({
state: 'active',
identityState: 'active',
identitySequence: 0,
deviceId,
authorizationId,
deviceLedgerSequence: 0,
},
);
} satisfies Exclude<AuthoritativeDeviceLifecycleState, { state: 'not-found' }>);
const identity = this.#states.get(subject);
if (identity?.state !== 'revoked') return Promise.resolve(device);
return Promise.resolve({
...device,
state: 'revoked',
identityState: 'revoked',
identitySequence: identity.sequence,
...(identity.revokedAt === undefined ? {} : { revokedAt: identity.revokedAt }),
});
}

public setDevice(
Expand Down
38 changes: 21 additions & 17 deletions apps/reference-rp/src/backend/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export class ReferenceRpRepository {
authorizationId: NexusDeviceAuthorizationIdV2;
}
| undefined;
let identitySequence: number;
if (input.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) {
const expectedV2: VerificationExpectationV2 = {
...expected,
Expand All @@ -289,10 +290,10 @@ export class ReferenceRpRepository {
input.proof,
expectedV2,
this.challenges,
this.lifecycle,
this.#requireDeviceLifecycle(),
);
subject = verified.subject;
identitySequence = verified.identitySequence;
deviceBinding = {
proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2,
deviceId: verified.deviceId,
Expand All @@ -306,6 +307,11 @@ export class ReferenceRpRepository {
this.lifecycle,
);
subject = verified.subject;
const lifecycle = await this.lifecycle.getAuthoritativeStatus(subject);
if (lifecycle.state !== 'active') {
throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401);
}
identitySequence = lifecycle.sequence;
}
const proofHash = canonicalSha256(input.proof);
const token = secureToken(32);
Expand All @@ -317,11 +323,6 @@ export class ReferenceRpRepository {
...(deviceBinding ?? {}),
};
this.#sessions.set(stored.tokenHash, stored);
const lifecycle = await this.lifecycle.getAuthoritativeStatus(subject);
if (lifecycle.state !== 'active') {
this.#sessions.delete(stored.tokenHash);
throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401);
}
const receipt = this.#recordReceipt({
operation: 'session.start',
resource: SESSION_RESOURCE,
Expand All @@ -338,7 +339,7 @@ export class ReferenceRpRepository {
subject,
friendlyName: this.#profiles.get(subject)?.friendlyName ?? null,
state: 'active',
sequence: lifecycle.sequence,
sequence: identitySequence,
expiresAt: stored.expiresAt,
checkedAt: this.#now(),
...(stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2
Expand All @@ -362,15 +363,11 @@ export class ReferenceRpRepository {
if (stored === undefined || stored.expiresAt <= now) {
throw new RpError('SESSION_INVALID', 'The RP session has expired.', 401);
}
const lifecycle = await this.lifecycle.getAuthoritativeStatus(stored.subject);
if (lifecycle.state !== 'active') {
throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401);
}
if (
stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 &&
stored.deviceId !== undefined &&
stored.authorizationId !== undefined
) {
let identitySequence: number;
if (stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) {
if (stored.deviceId === undefined || stored.authorizationId === undefined) {
throw new RpError('SESSION_INVALID', 'The RP session device binding is invalid.', 401);
}
const device = await this.#requireDeviceLifecycle().getAuthoritativeDeviceStatus(
stored.subject,
stored.deviceId,
Expand All @@ -383,14 +380,21 @@ export class ReferenceRpRepository {
) {
throw new RpError('SESSION_INVALID', 'The Nexus device is no longer active.', 401);
}
identitySequence = device.identitySequence;
} else {
const lifecycle = await this.lifecycle.getAuthoritativeStatus(stored.subject);
if (lifecycle.state !== 'active') {
throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401);
}
identitySequence = lifecycle.sequence;
}
return {
stored,
status: {
subject: stored.subject,
friendlyName: this.#profiles.get(stored.subject)?.friendlyName ?? null,
state: 'active',
sequence: lifecycle.sequence,
sequence: identitySequence,
expiresAt: stored.expiresAt,
checkedAt: now,
...(stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2
Expand Down
91 changes: 51 additions & 40 deletions apps/reference-rp/worker/reference-rp-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
maxClockSkewSeconds: MAX_CLOCK_SKEW_SECONDS,
};
let normalizedVerified: VerifiedSessionSigner;
let identitySequence: number;
if (input.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) {
const expectedV2: VerificationExpectationV2 = {
...expected,
Expand All @@ -326,46 +327,49 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
deviceId: verified.deviceId,
authorizationId: verified.authorizationId,
};
const device = await getAuthoritativeDeviceStatus(
this.env,
verified.subject,
verified.deviceId,
verified.authorizationId,
);
if (device.identityState !== 'active') {
throw new RpWorkerError(
'IDENTITY_REVOKED',
'This Nexus identity is revoked and cannot change notes.',
403,
);
}
if (device.deviceState !== 'active') {
throw new RpWorkerError(
'DEVICE_INACTIVE',
'This Nexus device is not active and cannot start a session.',
403,
);
}
identitySequence = device.identitySequence;
} else {
const verified = await verifyOwnershipProof(input.proof, expected);
normalizedVerified = {
subject: verified.subject,
proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1,
};
}
const lifecycle = await getAuthoritativeLifecycle(this.env, normalizedVerified.subject);
if (lifecycle.state === 'not-found') {
throw new RpWorkerError(
'IDENTITY_NOT_FOUND',
'This Nexus identity is not active in the authoritative registry.',
403,
);
}
if (lifecycle.state !== 'active') {
throw new RpWorkerError(
'IDENTITY_REVOKED',
'This Nexus identity is revoked and cannot change notes.',
403,
);
}
if (
normalizedVerified.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 &&
normalizedVerified.deviceId !== undefined &&
normalizedVerified.authorizationId !== undefined
) {
const device = await getAuthoritativeDeviceStatus(
this.env,
normalizedVerified.subject,
normalizedVerified.deviceId,
normalizedVerified.authorizationId,
);
if (device.identityState !== 'active' || device.deviceState !== 'active') {
const lifecycle = await getAuthoritativeLifecycle(this.env, verified.subject);
if (lifecycle.state === 'not-found') {
throw new RpWorkerError(
'DEVICE_INACTIVE',
'This Nexus device is not active and cannot start a session.',
'IDENTITY_NOT_FOUND',
'This Nexus identity is not active in the authoritative registry.',
403,
);
}
if (lifecycle.state !== 'active') {
throw new RpWorkerError(
'IDENTITY_REVOKED',
'This Nexus identity is revoked and cannot change notes.',
403,
);
}
identitySequence = lifecycle.sequence;
}

const proofHash = await canonicalSha256(input.proof);
Expand All @@ -387,7 +391,7 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
token,
tokenHash,
receiptId,
sequence: lifecycle.sequence,
sequence: identitySequence,
});
}

Expand Down Expand Up @@ -503,13 +507,6 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
throw new RpWorkerError('SESSION_INVALID', 'The RP session has expired.', 401);
}
const subject = row.subject as NexusSubject;
const lifecycle = await getAuthoritativeLifecycle(this.env, subject);
if (lifecycle.state === 'not-found') {
throw new RpWorkerError('SESSION_INVALID', 'The identity is no longer registered.', 401);
}
if (lifecycle.state !== 'active') {
throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401);
}
const v2Session = row.proof_protocol === OWNERSHIP_PROOF_PROTOCOL_V2;
if (
v2Session &&
Expand All @@ -520,6 +517,7 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
) {
throw new RpWorkerError('SESSION_INVALID', 'The RP session device binding is invalid.', 401);
}
let identitySequence: number;
if (v2Session && row.device_id !== null && row.authorization_id !== null) {
const deviceId = row.device_id as NexusDeviceIdV2;
const authorizationId = row.authorization_id as NexusDeviceAuthorizationIdV2;
Expand All @@ -529,9 +527,22 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
deviceId,
authorizationId,
);
if (device.identityState !== 'active' || device.deviceState !== 'active') {
if (device.identityState !== 'active') {
throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401);
}
if (device.deviceState !== 'active') {
throw new RpWorkerError('SESSION_INVALID', 'The Nexus device is no longer active.', 401);
}
identitySequence = device.identitySequence;
} else {
const lifecycle = await getAuthoritativeLifecycle(this.env, subject);
if (lifecycle.state === 'not-found') {
throw new RpWorkerError('SESSION_INVALID', 'The identity is no longer registered.', 401);
}
if (lifecycle.state !== 'active') {
throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401);
}
identitySequence = lifecycle.sequence;
}
return {
tokenHash,
Expand All @@ -540,7 +551,7 @@ export class ReferenceRpState extends DurableObject<ReferenceRpEnv> {
subject,
friendlyName: this.#friendlyName(subject),
state: 'active',
sequence: lifecycle.sequence,
sequence: identitySequence,
expiresAt: row.expires_at,
checkedAt: now,
...(v2Session
Expand Down
11 changes: 11 additions & 0 deletions apps/reference-rp/worker/reference-rp.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ describe('deployed reference RP Worker', () => {
deviceId: device.deviceId,
authorizationId: device.authorizationId,
});
await expect(lifecycleRequestCounts()).resolves.toEqual({ identity: 0, device: 1 });
const cookie = cookiePair(response.headers.get('Set-Cookie'));
const stored = await runInDurableObject(
env.RP_STATE.getByName('reference-rp-primary'),
Expand All @@ -291,6 +292,7 @@ describe('deployed reference RP Worker', () => {
friendlyName: 'revoked_device',
});
expect(denied.status).toBe(401);
await expect(lifecycleRequestCounts()).resolves.toEqual({ identity: 0, device: 2 });
});

it.each(['stale', 'forged', 'wrong-tuple'] as const)(
Expand All @@ -317,6 +319,7 @@ describe('deployed reference RP Worker', () => {
friendlyName: 'revoked_identity',
});
expect(denied.status).toBe(401);
await expect(lifecycleRequestCounts()).resolves.toEqual({ identity: 0, device: 2 });
});

it('rejects a v2 session at the exact device authorization expiry second', async () => {
Expand Down Expand Up @@ -617,6 +620,14 @@ async function lifecycleControl(
expect(response.ok).toBe(true);
}

async function lifecycleRequestCounts(): Promise<{ identity: number; device: number }> {
const response = await env.TEST_LIFECYCLE.fetch(
'https://lifecycle.test/__test/status-request-counts',
);
expect(response.ok).toBe(true);
return await response.json();
}

async function createRegisteredDevice(identity: TestIdentity): Promise<TestDevice> {
const pair = await provider.generateEd25519KeyPair({ privateKeyExtractable: true });
const publicKey = await provider.exportEd25519PublicKey(pair.publicKey);
Expand Down
15 changes: 14 additions & 1 deletion apps/reference-rp/worker/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const lifecycleService = `
const signature = 'A'.repeat(86);
const statusPrivateKey = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; // gitleaks:allow -- fixed test-only Ed25519 fixture
let deviceStatusMode = 'fresh';
let identityStatusRequests = 0;
let deviceStatusRequests = 0;

const decode = (value) => Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - value.length % 4) % 4)), (character) => character.charCodeAt(0));
const encode = (value) => btoa(String.fromCharCode(...value)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
Expand All @@ -29,8 +31,13 @@ const lifecycleService = `
identities.clear();
devices.clear();
deviceStatusMode = 'fresh';
identityStatusRequests = 0;
deviceStatusRequests = 0;
return Response.json({ ok: true });
}
if (url.pathname === '/__test/status-request-counts') {
return Response.json({ identity: identityStatusRequests, device: deviceStatusRequests });
}
if (url.pathname === '/__test/register') {
identities.set(body.subject, { genesis: body.genesis, state: 'active' });
return Response.json({ ok: true });
Expand All @@ -54,14 +61,19 @@ const lifecycleService = `
return Response.json({ ok: true });
}
if (url.pathname === '/v2/device/status') {
deviceStatusRequests += 1;
const device = devices.get(body.deviceId);
if (!device || device.subject !== body.subject || device.authorizationId !== body.authorizationId) {
return Response.json({ error: { code: 'DEVICE_NOT_FOUND', message: 'Not found.' } }, { status: 404 });
}
const now = Math.floor(Date.now() / 1000);
const identity = identities.get(body.subject);
const identityState = identity?.state === 'active' ? 'active' : 'revoked';
const deviceState = deviceStatusMode === 'expiry-boundary' ? 'expired' : device.state;
const deviceState = identityState === 'revoked'
? 'revoked'
: deviceStatusMode === 'expiry-boundary'
? 'expired'
: device.state;
const statementAuthorizationId = deviceStatusMode === 'wrong-tuple' ? 'nxa2_' + 'A'.repeat(43) : body.authorizationId;
const issuedAt = deviceStatusMode === 'stale' ? now - 120 : now;
const authorizationExpiresAt = deviceStatusMode === 'expiry-boundary' || deviceStatusMode === 'active-expiry-boundary' ? now : device.authorizationExpiresAt;
Expand Down Expand Up @@ -102,6 +114,7 @@ const lifecycleService = `
});
}
if (url.pathname !== '/v1/identity/status') return new Response('Not Found', { status: 404 });
identityStatusRequests += 1;
const identity = identities.get(body.subject);
if (!identity) return Response.json({ error: { code: 'IDENTITY_NOT_FOUND', message: 'Not found.' } }, { status: 404 });
const now = Math.floor(Date.now() / 1000);
Expand Down
Loading