diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 1ed8d090e8..a715587854 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1071,7 +1071,7 @@ "description": "Malformed publicKey, signature, or label" }, "401": { - "description": "Missing token, an invalid identity token, or a signature that does not verify" + "description": "Missing token, an invalid or already-spent identity token, or a signature that does not verify" }, "409": { "description": "Key or identity already claimed elsewhere, or the device limit is reached" @@ -2305,7 +2305,7 @@ }, "identityToken": { "type": "string", - "description": "CipherBox identity token this device signed in with; binds the account to the identity a pre-reconstruction device can present" + "description": "CipherBox identity token this device signed in with; binds the account to the identity a pre-reconstruction device can present. A successful registration spends it" }, "label": { "type": "string", diff --git a/apps/api/src/auth/auth.module.test.ts b/apps/api/src/auth/auth.module.test.ts index 9105e29028..319c4d443a 100644 --- a/apps/api/src/auth/auth.module.test.ts +++ b/apps/api/src/auth/auth.module.test.ts @@ -10,6 +10,7 @@ import { AuthMethod } from './entities/auth-method.entity'; import { AcceleratorToken } from './entities/accelerator-token.entity'; import { IdentitySubject } from './entities/identity-subject.entity'; import { RefreshToken } from './entities/refresh-token.entity'; +import { SpentIdentityToken } from './entities/spent-identity-token.entity'; import { User } from './entities/user.entity'; import { IdentityController } from './identity.controller'; import { EmailOtpService } from './services/email-otp.service'; @@ -81,7 +82,14 @@ describe('AuthModule dependency graph', () => { AuthModule, ], }); - for (const entity of [User, AuthMethod, RefreshToken, AcceleratorToken, IdentitySubject]) { + for (const entity of [ + User, + AuthMethod, + RefreshToken, + AcceleratorToken, + IdentitySubject, + SpentIdentityToken, + ]) { builder.overrideProvider(getRepositoryToken(entity)).useValue({}); } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 4b33a92d84..8e03a2c276 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -12,6 +12,7 @@ import { AuthMethod } from './entities/auth-method.entity'; import { AcceleratorToken } from './entities/accelerator-token.entity'; import { IdentitySubject } from './entities/identity-subject.entity'; import { RefreshToken } from './entities/refresh-token.entity'; +import { SpentIdentityToken } from './entities/spent-identity-token.entity'; import { User } from './entities/user.entity'; import { GatewayController } from './gateway.controller'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @@ -51,7 +52,14 @@ export function buildJwtOptions(configService: ConfigService) { @Module({ imports: [ - TypeOrmModule.forFeature([User, AuthMethod, RefreshToken, AcceleratorToken, IdentitySubject]), + TypeOrmModule.forFeature([ + User, + AuthMethod, + RefreshToken, + AcceleratorToken, + IdentitySubject, + SpentIdentityToken, + ]), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/apps/api/src/auth/entities/identity-subject.entity.ts b/apps/api/src/auth/entities/identity-subject.entity.ts index 599346f90f..313b2b0f09 100644 --- a/apps/api/src/auth/entities/identity-subject.entity.ts +++ b/apps/api/src/auth/entities/identity-subject.entity.ts @@ -35,10 +35,6 @@ export class IdentitySubject { @Column({ name: 'identifier_hash', type: 'varchar', length: 64 }) identifierHash: string; - /** Truncated human-readable identifier for account-settings display. */ - @Column({ name: 'identifier_display', type: 'varchar', length: 255, nullable: true }) - identifierDisplay: string | null; - @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) lastUsedAt: Date | null; diff --git a/apps/api/src/auth/entities/spent-identity-token.entity.ts b/apps/api/src/auth/entities/spent-identity-token.entity.ts new file mode 100644 index 0000000000..bf40f2f5a7 --- /dev/null +++ b/apps/api/src/auth/entities/spent-identity-token.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +/** + * An identity token a device registration has spent, kept until the token + * expires: the token is a bearer, so a replay is refused by its `jti` here. + */ +@Entity('spent_identity_tokens') +export class SpentIdentityToken { + @PrimaryColumn({ + name: 'token_id', + type: 'uuid', + primaryKeyConstraintName: 'pk_spent_identity_tokens', + }) + tokenId: string; + + /** Indexed to drive the expired-row sweep that runs beside each spend. */ + @Index('idx_spent_identity_tokens_expires_at') + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; +} diff --git a/apps/api/src/auth/identity.http.itest.ts b/apps/api/src/auth/identity.http.itest.ts index aa2d0f75bf..3fcda8db39 100644 --- a/apps/api/src/auth/identity.http.itest.ts +++ b/apps/api/src/auth/identity.http.itest.ts @@ -393,6 +393,21 @@ describe('identity exchange HTTP flows (real Postgres)', () => { expect(await userCount()).toBe(0); }); + it('keeps no display form of the identifier and no account on the subject row', async () => { + const columns: { column_name: string }[] = await db.dataSource.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'identity_subjects'` + ); + + expect(columns.map((row) => row.column_name).sort()).toEqual([ + 'created_at', + 'id', + 'identifier_hash', + 'kind', + 'last_used_at', + ]); + }); + it('does not cross-link methods that share an email', async () => { const shared = freshEmail(); const viaEmail = await emailGrant(shared); diff --git a/apps/api/src/auth/services/identity-exchange.service.ts b/apps/api/src/auth/services/identity-exchange.service.ts index b478e68157..32e4d8df87 100644 --- a/apps/api/src/auth/services/identity-exchange.service.ts +++ b/apps/api/src/auth/services/identity-exchange.service.ts @@ -39,7 +39,7 @@ export class IdentityExchangeService { async fromGoogleToken(idToken: string): Promise { const identity = await this.google.verify(idToken); - return this.mint('google', identity.subject, truncateEmail(identity.email), identity.email); + return this.mint('google', identity.subject, identity.email); } sendEmailCode(email: string): Promise { @@ -48,7 +48,7 @@ export class IdentityExchangeService { async fromEmailCode(email: string, code: string): Promise { const address = this.emailOtp.verify(email, code); - return this.mint('email', address, truncateEmail(address), address); + return this.mint('email', address, address); } async fromWalletSignature(message: string, signature: `0x${string}`): Promise { @@ -63,24 +63,16 @@ export class IdentityExchangeService { nonce, SIWE_LOGIN_STATEMENT ); - return this.mint('wallet', address, this.siwe.truncateWalletAddress(address), null); + return this.mint('wallet', address, null); } private async mint( method: IdentitySubjectKind, identifier: string, - identifierDisplay: string, email: string | null ): Promise { - const verifierId = await this.subjects.resolve(method, identifier, identifierDisplay); + const verifierId = await this.subjects.resolve(method, identifier); const { token, expiresAt } = await this.tokens.sign({ subject: verifierId, method }); return { token, verifierId, email, expiresAt }; } } - -/** Truncated address for display, e.g. "al***@example.com". */ -function truncateEmail(email: string): string { - const [local, domain] = email.split('@'); - if (!domain) return '***'; - return `${local.slice(0, 2)}***@${domain}`; -} diff --git a/apps/api/src/auth/services/identity-subject.service.test.ts b/apps/api/src/auth/services/identity-subject.service.test.ts index 0ca1f59794..84fbec761d 100644 --- a/apps/api/src/auth/services/identity-subject.service.test.ts +++ b/apps/api/src/auth/services/identity-subject.service.test.ts @@ -10,7 +10,6 @@ interface Row { id: string; kind: IdentitySubjectKind; identifierHash: string; - identifierDisplay: string | null; lastUsedAt: Date | null; } @@ -85,7 +84,7 @@ describe('IdentitySubjectService', () => { it('mints a subject on first sight and returns the inserted id', async () => { const repository = new FakeSubjectRepository(); - const id = await subjectService(repository).resolve('google', 'google-subject', 'me***@x.com'); + const id = await subjectService(repository).resolve('google', 'google-subject'); expect(repository.rows).toHaveLength(1); expect(id).toBe(repository.rows[0].id); @@ -94,7 +93,7 @@ describe('IdentitySubjectService', () => { it('stores the identifier only as its hash, never in plaintext', async () => { const repository = new FakeSubjectRepository(); - await subjectService(repository).resolve('email', 'member@example.com', 'me***@example.com'); + await subjectService(repository).resolve('email', 'member@example.com'); expect(repository.rows[0].identifierHash).toBe( new IdentityService().hashIdentifier('member@example.com') @@ -102,12 +101,28 @@ describe('IdentitySubjectService', () => { expect(JSON.stringify(repository.rows)).not.toContain('member@example.com'); }); + it('keeps no display form of the identifier beside its hash', async () => { + const repository = new FakeSubjectRepository(); + + await subjectService(repository).resolve( + 'wallet', + '0x52908400098527886E0F7030069857D2E4169EE7' + ); + + expect(Object.keys(repository.rows[0]).sort()).toEqual([ + 'id', + 'identifierHash', + 'kind', + 'lastUsedAt', + ]); + }); + it('returns the standing subject for an identity already seen', async () => { const repository = new FakeSubjectRepository(); const service = subjectService(repository); - const first = await service.resolve('wallet', '0xabc', '0xab***'); - const second = await service.resolve('wallet', '0xabc', '0xab***'); + const first = await service.resolve('wallet', '0xabc'); + const second = await service.resolve('wallet', '0xabc'); expect(second).toBe(first); expect(repository.rows).toHaveLength(1); @@ -117,8 +132,8 @@ describe('IdentitySubjectService', () => { const repository = new FakeSubjectRepository(); const service = subjectService(repository); - const viaEmail = await service.resolve('email', 'member@example.com', 'me***@example.com'); - const viaGoogle = await service.resolve('google', 'member@example.com', 'me***@example.com'); + const viaEmail = await service.resolve('email', 'member@example.com'); + const viaGoogle = await service.resolve('google', 'member@example.com'); expect(viaGoogle).not.toBe(viaEmail); expect(repository.rows).toHaveLength(2); @@ -131,7 +146,7 @@ describe('IdentitySubjectService', () => { const service = subjectService(repository); const resolved = await Promise.all( - Array.from({ length: 8 }, () => service.resolve('google', 'google-subject', 'me***@x.com')) + Array.from({ length: 8 }, () => service.resolve('google', 'google-subject')) ); expect(new Set(resolved).size).toBe(1); @@ -153,8 +168,8 @@ describe('IdentitySubjectService', () => { }; repository.createQueryBuilder = () => builder; - await expect( - subjectService(repository).resolve('google', 'google-subject', 'me***@x.com') - ).rejects.toThrow(InternalServerErrorException); + await expect(subjectService(repository).resolve('google', 'google-subject')).rejects.toThrow( + InternalServerErrorException + ); }); }); diff --git a/apps/api/src/auth/services/identity-subject.service.ts b/apps/api/src/auth/services/identity-subject.service.ts index a2bb5d34a0..c738274f01 100644 --- a/apps/api/src/auth/services/identity-subject.service.ts +++ b/apps/api/src/auth/services/identity-subject.service.ts @@ -28,11 +28,7 @@ export class IdentitySubjectService { * the loser, and the follow-up read returns the single winning row — so one * provider identity can never end up with two vaults. */ - async resolve( - kind: IdentitySubjectKind, - identifier: string, - identifierDisplay: string | null - ): Promise { + async resolve(kind: IdentitySubjectKind, identifier: string): Promise { const identifierHash = this.identityService.hashIdentifier(identifier); const now = this.clock.now(); @@ -46,7 +42,7 @@ export class IdentitySubjectService { .createQueryBuilder() .insert() .into(IdentitySubject) - .values({ kind, identifierHash, identifierDisplay, lastUsedAt: now }) + .values({ kind, identifierHash, lastUsedAt: now }) .orIgnore() .returning('id') .execute(); diff --git a/apps/api/src/auth/services/identity-token.service.test.ts b/apps/api/src/auth/services/identity-token.service.test.ts index 19a1b452a2..503f338343 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -1,29 +1,19 @@ import * as jose from 'jose'; -import { generateKeyPairSync } from 'node:crypto'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { FakeClock, fakeConfig } from '../../testing/fakes'; +import { randomUUID } from 'node:crypto'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { FakeClock } from '../../testing/fakes'; +import { + bootedIdentityTokenService as bootedService, + encodedIdentitySigningKey, + identityTokenWithJti, + identityTokenWithRawExp, +} from '../../testing/identity-tokens'; import { IDENTITY_TOKEN_AUDIENCE, IDENTITY_TOKEN_ISSUER, IdentityTokenService, } from './identity-token.service'; -/** A base64-encoded PKCS8 PEM, exactly as the env var carries it. */ -function encodedSigningKey(): string { - const { privateKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - }); - return Buffer.from(privateKey).toString('base64'); -} - -async function bootedService(values: Record, clock = new FakeClock()) { - const service = new IdentityTokenService(fakeConfig(values).service, clock); - await service.onModuleInit(); - return service; -} - /** The verification key a Web3Auth custom verifier would build from the JWKS. */ async function verificationKeyFrom(service: IdentityTokenService) { const [jwk] = service.jwks().keys; @@ -33,8 +23,8 @@ async function verificationKeyFrom(service: IdentityTokenService) { describe('IdentityTokenService', () => { let encodedPem: string; - beforeEach(() => { - encodedPem = encodedSigningKey(); + beforeAll(() => { + encodedPem = encodedIdentitySigningKey(); }); it('refuses to boot without a signing key in any deployed environment', async () => { @@ -91,7 +81,7 @@ describe('IdentityTokenService', () => { }); const impostor = await bootedService({ NODE_ENV: 'production', - IDENTITY_JWT_PRIVATE_KEY: encodedSigningKey(), + IDENTITY_JWT_PRIVATE_KEY: encodedIdentitySigningKey(), }); const { token } = await impostor.sign({ subject: 'subject-id', method: 'google' }); @@ -109,15 +99,96 @@ describe('IdentityTokenService', () => { ); const { token } = await service.sign({ subject: 'subject-id', method: 'google' }); - await expect(service.verify(token)).resolves.toEqual({ + await expect(service.verify(token)).resolves.toMatchObject({ subject: 'subject-id', method: 'google', + expiresAt: new Date(clock.now().getTime() + 300_000), }); clock.advanceMs(300_001); await expect(service.verify(token)).rejects.toThrow(jose.errors.JWTExpired); }); + it('gives every minted token its own token id', async () => { + const service = await bootedService({ NODE_ENV: 'test' }); + const claims = { subject: 'subject-id', method: 'google' } as const; + + const first = await service.verify((await service.sign(claims)).token); + const second = await service.verify((await service.sign(claims)).token); + + expect(first.tokenId).not.toBe(second.tokenId); + }); + + it.each([ + { + name: 'that carries no token id, since no spend could record it', + jti: undefined, + refusal: jose.errors.JWTClaimValidationFailed, + }, + { + name: 'whose token id is not a UUID, before any spend reads it', + jti: 'not-a-uuid', + refusal: /token id/, + }, + ])('refuses a token $name', async ({ jti, refusal }) => { + const clock = new FakeClock(); + const service = await bootedService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + + const token = await identityTokenWithJti(encodedPem, clock, jti); + await expect(service.verify(token)).rejects.toThrow(refusal); + }); + + it('refuses a token that carries no expiry', async () => { + const clock = new FakeClock(); + const service = await bootedService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + + const unbounded = await identityTokenWithJti(encodedPem, clock, randomUUID(), 'omitted'); + await expect(service.verify(unbounded)).rejects.toThrow(jose.errors.JWTClaimValidationFailed); + }); + + it('accepts a hand-signed token whose expiry is an ordinary instant', async () => { + const clock = new FakeClock(); + const service = await bootedService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + const exp = Math.floor(clock.now().getTime() / 1000) + 300; + + const verified = await service.verify(await identityTokenWithRawExp(encodedPem, String(exp))); + expect(verified.expiresAt).toEqual(new Date(exp * 1000)); + }); + + it.each(['1e999', '1e300'])( + 'refuses a token whose expiry %s is no valid instant', + async (exp) => { + const service = await bootedService({ + NODE_ENV: 'production', + IDENTITY_JWT_PRIVATE_KEY: encodedPem, + }); + + await expect(service.verify(await identityTokenWithRawExp(encodedPem, exp))).rejects.toThrow( + 'valid expiry' + ); + } + ); + + it('refuses a token whose expiry is past, even when it is not finite', async () => { + const service = await bootedService({ + NODE_ENV: 'production', + IDENTITY_JWT_PRIVATE_KEY: encodedPem, + }); + + await expect( + service.verify(await identityTokenWithRawExp(encodedPem, '-1e999')) + ).rejects.toThrow(jose.errors.JWTExpired); + }); + it('expires the token on the injected clock, not the wall clock', async () => { const clock = new FakeClock(); const service = await bootedService( diff --git a/apps/api/src/auth/services/identity-token.service.ts b/apps/api/src/auth/services/identity-token.service.ts index 88bb154d58..ff269c94a1 100644 --- a/apps/api/src/auth/services/identity-token.service.ts +++ b/apps/api/src/auth/services/identity-token.service.ts @@ -1,14 +1,18 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as jose from 'jose'; import { createPublicKey } from 'node:crypto'; +import { EntityManager } from 'typeorm'; import { Clock } from '../../common/clock'; +import { Entropy } from '../../common/entropy'; +import { UUID_RE } from '../../common/patterns'; import { IDENTITY_SUBJECT_KINDS, type IdentitySubjectKind, } from '../entities/identity-subject.entity'; +import { SpentIdentityToken } from '../entities/spent-identity-token.entity'; -const KID = 'cipherbox-identity-1'; +export const IDENTITY_TOKEN_KID = 'cipherbox-identity-1'; const ALGORITHM = 'RS256'; /** Who mints the token, and who is entitled to verify it. */ @@ -18,12 +22,27 @@ export const IDENTITY_TOKEN_AUDIENCE = 'web3auth'; /** Long enough for the Core Kit handshake, short enough that a leak is stale. */ const TOKEN_TTL_SECONDS = 300; +/** Expired rows one spend reclaims; each spend adds one row, so the table tracks its live set. */ +const SPENT_SWEEP_BATCH = 100; + +/** + * How long past its token's expiry a spent row survives, so an instance whose + * clock runs behind still finds the row for as long as it accepts the token. + */ +const SPENT_ROW_GRACE_MS = 60_000; + export interface IdentityTokenClaims { /** The `identity_subjects` row id — the Core Kit `verifierId`. */ subject: string; method: IdentitySubjectKind; } +export interface VerifiedIdentityToken extends IdentityTokenClaims { + /** The token's `jti`, the key a spend records. */ + tokenId: string; + expiresAt: Date; +} + /** * Mints the identity token the Core Kit consumes, and serves the JWKS the * Web3Auth custom verifier fetches to check it (ADR 0008 D1). @@ -35,7 +54,8 @@ export class IdentityTokenService implements OnModuleInit { constructor( private readonly configService: ConfigService, - private readonly clock: Clock + private readonly clock: Clock, + private readonly entropy: Entropy ) {} async onModuleInit(): Promise { @@ -82,8 +102,9 @@ export class IdentityTokenService implements OnModuleInit { const issuedAt = Math.floor(this.clock.now().getTime() / 1000); const expiresAt = issuedAt + TOKEN_TTL_SECONDS; const token = await new jose.SignJWT({ method: claims.method }) - .setProtectedHeader({ alg: ALGORITHM, kid: KID }) + .setProtectedHeader({ alg: ALGORITHM, kid: IDENTITY_TOKEN_KID }) .setSubject(claims.subject) + .setJti(this.entropy.randomUuid()) .setIssuer(IDENTITY_TOKEN_ISSUER) .setAudience(IDENTITY_TOKEN_AUDIENCE) .setIssuedAt(issuedAt) @@ -98,7 +119,7 @@ export class IdentityTokenService implements OnModuleInit { * it holds. Issuer and audience are pinned, so a token minted for some other * relying party cannot be replayed here. */ - async verify(token: string): Promise { + async verify(token: string): Promise { const { payload } = await jose.jwtVerify( token, await jose.importJWK(this.publicJwk, ALGORITHM), @@ -106,16 +127,52 @@ export class IdentityTokenService implements OnModuleInit { issuer: IDENTITY_TOKEN_ISSUER, audience: IDENTITY_TOKEN_AUDIENCE, algorithms: [ALGORITHM], + requiredClaims: ['exp', 'jti'], // Expiry reads the injected clock, the same seam `sign` stamps from. currentDate: this.clock.now(), } ); - const subject = payload.sub; - const method = payload.method; + const { sub: subject, method, jti: tokenId, exp } = payload; if (typeof subject !== 'string' || !isIdentitySubjectKind(method)) { throw new Error('identity token is missing its subject or method claim'); } - return { subject, method }; + // `jose` accepts any JSON number as `exp`; `1e999` and `1e300` give no valid Date. + const expiresAt = new Date((exp ?? NaN) * 1000); + if ( + typeof tokenId !== 'string' || + !UUID_RE.test(tokenId) || + Number.isNaN(expiresAt.getTime()) + ) { + throw new Error('identity token is missing its token id or a valid expiry'); + } + return { subject, method, tokenId, expiresAt }; + } + + /** + * Spend a verified token on the caller's transaction, so a registration the + * caller then refuses rolls the spend back with it. The primary key makes a + * concurrent spend of the same token wait for this one and then refuse. + */ + async spend(manager: EntityManager, token: VerifiedIdentityToken): Promise { + const inserted = await manager + .createQueryBuilder() + .insert() + .into(SpentIdentityToken) + .values({ tokenId: token.tokenId, expiresAt: token.expiresAt }) + .orIgnore() + .returning('token_id') + .execute(); + if ((inserted.raw as unknown[]).length === 0) { + throw new UnauthorizedException('Identity token already used'); + } + // Swept after the insert, so a replay never pays for a sweep. `SKIP LOCKED` + // yields rows a concurrent spend is already reclaiming. + await manager.query( + `DELETE FROM spent_identity_tokens WHERE ctid IN ( + SELECT ctid FROM spent_identity_tokens WHERE expires_at <= $1 + ORDER BY expires_at LIMIT $2 FOR UPDATE SKIP LOCKED)`, + [new Date(this.clock.now().getTime() - SPENT_ROW_GRACE_MS), SPENT_SWEEP_BATCH] + ); } /** @@ -123,7 +180,12 @@ export class IdentityTokenService implements OnModuleInit { * private JWK, so no private field can reach the JWKS by omission. */ private async exportPublicJwk(publicKey: jose.CryptoKey | jose.KeyObject): Promise { - return { ...(await jose.exportJWK(publicKey)), kid: KID, alg: ALGORITHM, use: 'sig' }; + return { + ...(await jose.exportJWK(publicKey)), + kid: IDENTITY_TOKEN_KID, + alg: ALGORITHM, + use: 'sig', + }; } } diff --git a/apps/api/src/device-approval/device-approval.http.itest.ts b/apps/api/src/device-approval/device-approval.http.itest.ts index 33c70df7bf..27f18d0cb6 100644 --- a/apps/api/src/device-approval/device-approval.http.itest.ts +++ b/apps/api/src/device-approval/device-approval.http.itest.ts @@ -4,6 +4,7 @@ import { createHash, randomUUID } from 'node:crypto'; import request from 'supertest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { RefreshToken } from '../auth/entities/refresh-token.entity'; +import { SpentIdentityToken } from '../auth/entities/spent-identity-token.entity'; import { User } from '../auth/entities/user.entity'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { IdentityTokenService } from '../auth/services/identity-token.service'; @@ -121,7 +122,7 @@ describe('device-approval HTTP surface (real Postgres)', () => { beforeEach(async () => { await db.dataSource.query( - 'TRUNCATE TABLE users, identity_subjects, account_devices, device_approvals, refresh_tokens CASCADE' + 'TRUNCATE TABLE users, identity_subjects, spent_identity_tokens, account_devices, device_approvals, refresh_tokens CASCADE' ); }); @@ -502,6 +503,92 @@ describe('device-approval HTTP surface (real Postgres)', () => { }); }); + describe('the identity token a registration spends', () => { + function registration(account: { userId: string }, device: TestDeviceKey, token: string) { + return { + publicKey: device.publicKey, + signature: device.sign(deviceRegistrationPayload(account.userId, device.publicKey)), + identityToken: token, + }; + } + + function post(account: { token: string }, body: object) { + return request(http()) + .post('/devices') + .set('Authorization', `Bearer ${account.token}`) + .send(body); + } + + it('401s a second registration that replays a spent token, and writes nothing', async () => { + const account = await seedAccount(db, jwt); + const token = await identityToken(randomUUID()); + await post(account, registration(account, createTestDeviceKey(), token)).expect(201); + + await post(account, registration(account, createTestDeviceKey(), token)).expect(401); + expect(await db.dataSource.getRepository(AccountDevice).count()).toBe(1); + }); + + it('401s a replay from another account before any identity check', async () => { + const member = await seedAccount(db, jwt); + const token = await identityToken(randomUUID()); + await post(member, registration(member, createTestDeviceKey(), token)).expect(201); + + const other = await seedAccount(db, jwt); + await post(other, registration(other, createTestDeviceKey(), token)).expect(401); + }); + + it('still opens a rendezvous session with a token a registration spent', async () => { + const account = await seedAccount(db, jwt); + const token = await identityToken(randomUUID()); + await post(account, registration(account, createTestDeviceKey(), token)).expect(201); + + await request(http()) + .post('/device-approval/session') + .send({ identityToken: token }) + .expect(200); + }); + + it('leaves the token unspent when the registration is refused', async () => { + const member = await enroll('member'); + const token = await identityToken(member.identitySubject); + const other = await seedAccount(db, jwt); + await post(other, registration(other, createTestDeviceKey(), token)).expect(409); + + await post(member, registration(member, createTestDeviceKey(), token)).expect(201); + }); + + it('lets exactly one of two simultaneous registrations spend one token', async () => { + const account = await seedAccount(db, jwt); + const token = await identityToken(randomUUID()); + const results = await Promise.all([ + post(account, registration(account, createTestDeviceKey(), token)), + post(account, registration(account, createTestDeviceKey(), token)), + ]); + + expect(results.map((res) => res.status).sort()).toEqual([201, 401]); + expect(await db.dataSource.getRepository(AccountDevice).count()).toBe(1); + }); + + it('reclaims a spent row past its grace, and keeps one its token could still pass', async () => { + const spent = db.dataSource.getRepository(SpentIdentityToken); + const stale = randomUUID(); + const recent = randomUUID(); + await spent.insert([ + { tokenId: stale, expiresAt: new Date(clock.now().getTime() - 120_000) }, + { tokenId: recent, expiresAt: new Date(clock.now().getTime() - 1_000) }, + ]); + + const account = await seedAccount(db, jwt); + const token = await identityToken(randomUUID()); + await post(account, registration(account, createTestDeviceKey(), token)).expect(201); + + const kept = (await spent.find()).map((row) => row.tokenId); + expect(kept).not.toContain(stale); + expect(kept).toContain(recent); + expect(kept).toHaveLength(2); + }); + }); + describe('expiry', () => { it('404s an expired rendezvous and leaves no row behind', async () => { const account = await enroll(); diff --git a/apps/api/src/device-approval/device.controller.ts b/apps/api/src/device-approval/device.controller.ts index 00998eafe4..ab081689eb 100644 --- a/apps/api/src/device-approval/device.controller.ts +++ b/apps/api/src/device-approval/device.controller.ts @@ -35,7 +35,8 @@ export class DeviceController { @ApiResponse({ status: 400, description: 'Malformed publicKey, signature, or label' }) @ApiResponse({ status: 401, - description: 'Missing token, an invalid identity token, or a signature that does not verify', + description: + 'Missing token, an invalid or already-spent identity token, or a signature that does not verify', }) @ApiResponse({ status: 409, diff --git a/apps/api/src/device-approval/dto/device-approval.dto.ts b/apps/api/src/device-approval/dto/device-approval.dto.ts index a27634ac59..6be101c449 100644 --- a/apps/api/src/device-approval/dto/device-approval.dto.ts +++ b/apps/api/src/device-approval/dto/device-approval.dto.ts @@ -38,7 +38,7 @@ export class RegisterDeviceDto { @ApiProperty({ description: 'CipherBox identity token this device signed in with; binds the account to the identity ' + - 'a pre-reconstruction device can present', + 'a pre-reconstruction device can present. A successful registration spends it', }) @IsString() @MaxLength(4096) diff --git a/apps/api/src/device-approval/services/account-device.service.test.ts b/apps/api/src/device-approval/services/account-device.service.test.ts index 7c0b1355b6..3f2faabbcb 100644 --- a/apps/api/src/device-approval/services/account-device.service.test.ts +++ b/apps/api/src/device-approval/services/account-device.service.test.ts @@ -1,12 +1,18 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { QueryFailedError } from 'typeorm'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { IdentityTokenService } from '../../auth/services/identity-token.service'; import { FakeDataSource } from '../../testing/fake-data-source'; import { FakeRepository } from '../../testing/fake-repo'; import { createTestDeviceKey, TestDeviceKey } from '../../testing/device-keys'; import { FakeClock, fakeConfig } from '../../testing/fakes'; +import { + bootedIdentityTokenService, + encodedIdentitySigningKey, + identityTokenWithJti, + identityTokenWithRawExp, +} from '../../testing/identity-tokens'; import { deviceRegistrationPayload } from '../device-signature'; import { AccountDevice } from '../entities/account-device.entity'; import { AccountDeviceService, RegisterDeviceInput } from './account-device.service'; @@ -23,14 +29,15 @@ describe('AccountDeviceService', () => { let clock: FakeClock; let service: AccountDeviceService; let subjects: Map; + let spent: Set; let account: string; let token: string; let device: TestDeviceKey; - /** Mints an identity token the stubbed verifier resolves to a fresh subject. */ - function mintIdentityToken(): string { + /** Mints an identity token the stubbed verifier resolves to `subject`. */ + function mintIdentityToken(subject: string = randomUUID()): string { const value = `token-${randomUUID()}`; - subjects.set(value, randomUUID()); + subjects.set(value, subject); return value; } @@ -38,7 +45,10 @@ describe('AccountDeviceService', () => { return subjects.get(identityToken) as string; } - /** A registration signed by `key` over `signedAccount` (defaults to honest). */ + /** + * A registration signed by `key` over `signedAccount` (defaults to honest), + * presenting a fresh token for the identity `token` names: a token is spent. + */ function registration( key: TestDeviceKey, signedAccount: string, @@ -47,7 +57,7 @@ describe('AccountDeviceService', () => { return { publicKey: key.publicKey, signature: key.sign(deviceRegistrationPayload(signedAccount, key.publicKey)), - identityToken: token, + identityToken: mintIdentityToken(subjectOf(token)), ...overrides, }; } @@ -56,16 +66,30 @@ describe('AccountDeviceService', () => { devices = new FakeRepository(); clock = new FakeClock(); subjects = new Map(); + spent = new Set(); const identityTokens = { verify: async (value: string) => { const subject = subjects.get(value); if (!subject) { throw new Error('identity token does not verify'); } - return { subject, method: 'google' as const }; + return { subject, method: 'google' as const, tokenId: value, expiresAt: clock.now() }; + }, + spend: async (_manager: unknown, verified: { tokenId: string }) => { + if (spent.has(verified.tokenId)) { + throw new UnauthorizedException('Identity token already used'); + } + spent.add(verified.tokenId); }, } as unknown as IdentityTokenService; - service = new AccountDeviceService( + service = serviceOver(identityTokens, config); + } + + function serviceOver( + identityTokens: IdentityTokenService, + config: Record = {} + ) { + return new AccountDeviceService( devices as never, new FakeDataSource(devices as never) as never, identityTokens, @@ -82,6 +106,22 @@ describe('AccountDeviceService', () => { }); describe('register', () => { + let encodedPem: string; + + beforeAll(() => { + encodedPem = encodedIdentitySigningKey(); + }); + + /** Puts `service` over a real token service, and returns the spy on its `spend`. */ + async function overRealTokens() { + const realTokens = await bootedIdentityTokenService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + service = serviceOver(realTokens); + return vi.spyOn(realTokens, 'spend'); + } + it('creates the row from the proven account, the identity subject and the key', async () => { const created = await service.register( account, @@ -147,6 +187,52 @@ describe('AccountDeviceService', () => { expect(devices.rows).toHaveLength(0); }); + it('refuses a replayed identity token, and writes nothing', async () => { + await service.register(account, registration(device, account, { identityToken: token })); + + await expect( + service.register( + account, + registration(createTestDeviceKey(), account, { identityToken: token }) + ) + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(devices.rows).toHaveLength(1); + }); + + it('refuses a replayed token before the identity check, so a refusal names the replay', async () => { + await service.register(account, registration(device, account, { identityToken: token })); + + const otherAccount = randomUUID(); + await expect( + service.register( + otherAccount, + registration(createTestDeviceKey(), otherAccount, { identityToken: token }) + ) + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('refuses a token whose token id is not a UUID with 401, before the uuid column sees it', async () => { + const spend = await overRealTokens(); + + const malformed = await identityTokenWithJti(encodedPem, clock, 'not-a-uuid'); + await expect( + service.register(account, registration(device, account, { identityToken: malformed })) + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(spend).not.toHaveBeenCalled(); + expect(devices.rows).toHaveLength(0); + }); + + it('refuses a token whose expiry is not finite with 401, before any spend', async () => { + const spend = await overRealTokens(); + + const unbounded = await identityTokenWithRawExp(encodedPem, '1e999'); + await expect( + service.register(account, registration(device, account, { identityToken: unbounded })) + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(spend).not.toHaveBeenCalled(); + expect(devices.rows).toHaveLength(0); + }); + it('is idempotent per key: a re-registration updates rather than duplicates', async () => { const first = await service.register( account, diff --git a/apps/api/src/device-approval/services/account-device.service.ts b/apps/api/src/device-approval/services/account-device.service.ts index 87f7cd8556..85f756cd88 100644 --- a/apps/api/src/device-approval/services/account-device.service.ts +++ b/apps/api/src/device-approval/services/account-device.service.ts @@ -2,7 +2,10 @@ import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/co import { ConfigService } from '@nestjs/config'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, QueryFailedError, Repository } from 'typeorm'; -import { IdentityTokenService } from '../../auth/services/identity-token.service'; +import { + IdentityTokenService, + type VerifiedIdentityToken, +} from '../../auth/services/identity-token.service'; import { advisoryLockKey, boundedAcquire, @@ -81,15 +84,15 @@ export class AccountDeviceService { throw new UnauthorizedException('Device signature does not verify'); } - let identitySubjectId: string; + let identity: VerifiedIdentityToken; try { - identitySubjectId = (await this.identityTokens.verify(input.identityToken)).subject; + identity = await this.identityTokens.verify(input.identityToken); } catch { throw new UnauthorizedException('Invalid identity token'); } try { - return await this.claim(userId, identitySubjectId, input); + return await this.claim(userId, identity, input); } catch (error) { // The unique public-key index is the durable backstop under a concurrent // double-register: the loser's transaction aborts, so re-read the committed @@ -117,15 +120,17 @@ export class AccountDeviceService { */ private async claim( userId: string, - identitySubjectId: string, + identity: VerifiedIdentityToken, input: RegisterDeviceInput ): Promise { + const identitySubjectId = identity.subject; return runLockGuardedTransaction(this.dataSource, async (manager) => { await boundedAcquire( manager, [subjectLockKey(identitySubjectId), registryLockKey(userId)], this.lockTimeoutMs ); + await this.identityTokens.spend(manager, identity); const repo = manager.getRepository(AccountDevice); const now = this.clock.now(); diff --git a/apps/api/src/migrations/1790640000000-DropIdentitySubjectDisplay.ts b/apps/api/src/migrations/1790640000000-DropIdentitySubjectDisplay.ts new file mode 100644 index 0000000000..c4476a5601 --- /dev/null +++ b/apps/api/src/migrations/1790640000000-DropIdentitySubjectDisplay.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropIdentitySubjectDisplay1790640000000 implements MigrationInterface { + name = 'DropIdentitySubjectDisplay1790640000000'; + + // IF EXISTS: two runners that start together both reach this statement; the + // second waits on the first's table lock and then finds nothing to drop. + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "identity_subjects" DROP COLUMN IF EXISTS "identifier_display"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "identity_subjects" ADD COLUMN IF NOT EXISTS "identifier_display" character varying(255)` + ); + } +} diff --git a/apps/api/src/migrations/1790640000001-AddSpentIdentityTokens.ts b/apps/api/src/migrations/1790640000001-AddSpentIdentityTokens.ts new file mode 100644 index 0000000000..84fc0ca856 --- /dev/null +++ b/apps/api/src/migrations/1790640000001-AddSpentIdentityTokens.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSpentIdentityTokens1790640000001 implements MigrationInterface { + name = 'AddSpentIdentityTokens1790640000001'; + + public async up(queryRunner: QueryRunner): Promise { + // Two runners that start together race `CREATE TABLE IF NOT EXISTS` on the + // catalog; the transaction lock makes the second wait and then find the table. + await queryRunner.query( + `SELECT pg_advisory_xact_lock(hashtext('migration:AddSpentIdentityTokens'))` + ); + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "spent_identity_tokens" ("token_id" uuid NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "pk_spent_identity_tokens" PRIMARY KEY ("token_id"))` + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_spent_identity_tokens_expires_at" ON "spent_identity_tokens" ("expires_at") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "public"."idx_spent_identity_tokens_expires_at"`); + await queryRunner.query(`DROP TABLE IF EXISTS "spent_identity_tokens"`); + } +} diff --git a/apps/api/src/testing/identity-tokens.ts b/apps/api/src/testing/identity-tokens.ts new file mode 100644 index 0000000000..aa2fd2be20 --- /dev/null +++ b/apps/api/src/testing/identity-tokens.ts @@ -0,0 +1,80 @@ +import * as jose from 'jose'; +import { generateKeyPairSync, randomUUID } from 'node:crypto'; +import { + IDENTITY_TOKEN_AUDIENCE, + IDENTITY_TOKEN_ISSUER, + IDENTITY_TOKEN_KID, + IdentityTokenService, +} from '../auth/services/identity-token.service'; +import { FakeClock, fakeConfig, FakeEntropy } from './fakes'; + +/** The protected header the API's own mint stamps. */ +const HEADER = { alg: 'RS256', kid: IDENTITY_TOKEN_KID }; + +function identitySigningKey(encodedPem: string) { + return jose.importPKCS8(Buffer.from(encodedPem, 'base64').toString('utf8'), 'RS256'); +} + +/** A base64-encoded PKCS8 PEM, exactly as `IDENTITY_JWT_PRIVATE_KEY` carries it. */ +export function encodedIdentitySigningKey(): string { + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + return Buffer.from(privateKey).toString('base64'); +} + +/** A real token service, booted on `values` as the module boots it. */ +export async function bootedIdentityTokenService( + values: Record, + clock = new FakeClock() +): Promise { + const service = new IdentityTokenService(fakeConfig(values).service, clock, new FakeEntropy()); + await service.onModuleInit(); + return service; +} + +/** + * An identity token under the configured key whose `jti` the caller picks, or + * omits, and whose expiry the caller may omit: the shapes the API's own mint + * never produces. + */ +export async function identityTokenWithJti( + encodedPem: string, + clock: FakeClock, + jti: string | undefined, + expiry: 'stamped' | 'omitted' = 'stamped' +): Promise { + const issuedAt = Math.floor(clock.now().getTime() / 1000); + const builder = new jose.SignJWT({ method: 'google' }) + .setProtectedHeader(HEADER) + .setSubject('subject-id') + .setIssuer(IDENTITY_TOKEN_ISSUER) + .setAudience(IDENTITY_TOKEN_AUDIENCE) + .setIssuedAt(issuedAt); + if (expiry === 'stamped') builder.setExpirationTime(issuedAt + 300); + if (jti !== undefined) builder.setJti(jti); + return builder.sign(await identitySigningKey(encodedPem)); +} + +/** + * An identity token under the configured key whose `exp` is the raw JSON number + * `expJson`, such as `1e999`. Signed as raw bytes, since `SignJWT` refuses such values. + */ +export async function identityTokenWithRawExp( + encodedPem: string, + expJson: string +): Promise { + const claims = JSON.stringify({ + iss: IDENTITY_TOKEN_ISSUER, + aud: IDENTITY_TOKEN_AUDIENCE, + sub: 'subject-id', + method: 'google', + jti: randomUUID(), + }); + const payload = Buffer.from(`${claims.slice(0, -1)},"exp":${expJson}}`); + return new jose.CompactSign(payload) + .setProtectedHeader(HEADER) + .sign(await identitySigningKey(encodedPem)); +} diff --git a/apps/api/src/testing/integration-db.ts b/apps/api/src/testing/integration-db.ts index 3d5eed0493..09e690b565 100644 --- a/apps/api/src/testing/integration-db.ts +++ b/apps/api/src/testing/integration-db.ts @@ -4,6 +4,7 @@ import { AuthMethod } from '../auth/entities/auth-method.entity'; import { AcceleratorToken } from '../auth/entities/accelerator-token.entity'; import { IdentitySubject } from '../auth/entities/identity-subject.entity'; import { RefreshToken } from '../auth/entities/refresh-token.entity'; +import { SpentIdentityToken } from '../auth/entities/spent-identity-token.entity'; import { User } from '../auth/entities/user.entity'; import { AccountDevice } from '../device-approval/entities/account-device.entity'; import { DeviceApproval } from '../device-approval/entities/device-approval.entity'; @@ -17,6 +18,8 @@ import { AddNameInventoryAndPinnedCids1784566605863 } from '../migrations/178456 import { AddPinReferences1788134400000 } from '../migrations/1788134400000-AddPinReferences'; import { AddRecordCache1784600557946 } from '../migrations/1784600557946-AddRecordCache'; import { AddRefreshTokenExpiresAtIndex1789358810000 } from '../migrations/1789358810000-AddRefreshTokenExpiresAtIndex'; +import { AddSpentIdentityTokens1790640000001 } from '../migrations/1790640000001-AddSpentIdentityTokens'; +import { DropIdentitySubjectDisplay1790640000000 } from '../migrations/1790640000000-DropIdentitySubjectDisplay'; import { InitAuthSchema1784513040045 } from '../migrations/1784513040045-InitAuthSchema'; import { NameInventory } from '../registry/entities/name-inventory.entity'; import { PinReference } from '../registry/entities/pin-reference.entity'; @@ -48,6 +51,7 @@ const ENTITIES = [ MailboxMessage, RecordCache, IdentitySubject, + SpentIdentityToken, AccountDevice, DeviceApproval, ]; @@ -62,6 +66,8 @@ const MIGRATIONS = [ AddAcceleratorTokens1787681144572, AddPinReferences1788134400000, AddRefreshTokenExpiresAtIndex1789358810000, + DropIdentitySubjectDisplay1790640000000, + AddSpentIdentityTokens1790640000001, ]; export interface IntegrationDatabase { diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts index c4e31163b2..263a6c49b1 100644 --- a/apps/web/src/auth/coreKit.ts +++ b/apps/web/src/auth/coreKit.ts @@ -62,6 +62,8 @@ export interface WebCoreKitSession extends CoreKitSession { deviceIdentity(): DeviceIdentity | null; /** The identity token this sign-in used, which the device surface presents. */ identityToken(): string | null; + /** Drops the identity token once a device registration has spent it. */ + dropIdentityToken(): void; /** * A fresh factor for a device this session approves, and never this session's * own (ADR 0009 D5). The bytes are the caller's to seal and then to erase. @@ -381,6 +383,10 @@ class Web3AuthSession implements WebCoreKitSession { return this.signedInToken; } + dropIdentityToken(): void { + this.signedInToken = null; + } + async mintApprovalFactor(): Promise { if (!this.isLoggedIn()) throw new Error('sign in before you approve a device'); // Refused rather than risked: this mint's own sync writes every queued diff --git a/apps/web/src/components/settings/DevicesPane.test.tsx b/apps/web/src/components/settings/DevicesPane.test.tsx index c28c4ae6b0..5934a4c2eb 100644 --- a/apps/web/src/components/settings/DevicesPane.test.tsx +++ b/apps/web/src/components/settings/DevicesPane.test.tsx @@ -167,6 +167,32 @@ describe('the authorized devices pane', () => { expect(engine.calls.registered).toEqual([]); }); + // A registration spends the token of this sign-in, so after a revoke the + // control asks for a fresh sign-in rather than fail against the API. + it('closes the register control once a registration has spent the token', async () => { + const calls = await pane([], [OWN], []); + await waitFor(() => expect(screen.getByTestId('settings-device-register')).toBeTruthy()); + await act(async () => { + fireEvent.click(screen.getByTestId('settings-device-register')); + }); + await waitFor(() => expect(rows()).toHaveLength(1)); + + await act(async () => { + fireEvent.click(screen.getByTestId('settings-device-revoke')); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('settings-device-revoke-confirm')); + }); + + const register = await waitFor(() => screen.getByTestId('settings-device-register')); + expect(register.getAttribute('disabled')).not.toBeNull(); + expect(register.getAttribute('aria-label')).toContain('sign in again'); + await act(async () => { + fireEvent.click(register); + }); + expect(calls.registered).toHaveLength(1); + }); + // `forgetDevice` leaves the session holding no key. Keeping the last answer // would mark a listed row as this device and hide the way back in. it('offers registration again once this browser holds no identity key', async () => { diff --git a/apps/web/src/hooks/useDevices.ts b/apps/web/src/hooks/useDevices.ts index 39692ccc98..95c84ff328 100644 --- a/apps/web/src/hooks/useDevices.ts +++ b/apps/web/src/hooks/useDevices.ts @@ -76,6 +76,9 @@ export function useDevices(): DevicesRead { const challenge = await facade.deviceRegistrationChallenge(publicKey); const signature = await identity.sign(Uint8Array.from(challenge)); await facade.registerDevice(publicKey, signature, identityToken, null); + // The API refuses a spent token, so a second registration in this + // sign-in could only fail; the pane then asks for a fresh sign-in. + session?.dropIdentityToken(); setThisDevice(publicKey); await read(facade); }), diff --git a/apps/web/src/test/authFakes.tsx b/apps/web/src/test/authFakes.tsx index 6c06645986..e66cd7aabb 100644 --- a/apps/web/src/test/authFakes.tsx +++ b/apps/web/src/test/authFakes.tsx @@ -558,6 +558,9 @@ export function fakeCoreKitSession( forgetDevice: () => Promise.resolve(), deviceIdentity: () => (options.noDeviceIdentity === true ? null : device), identityToken: () => identityToken, + dropIdentityToken() { + identityToken = null; + }, mintApprovalFactor() { const key = new Uint8Array(32).fill(0x5a); calls.mintedFactors.push(key); diff --git a/blueprint/api.md b/blueprint/api.md index 98eb098591..85747cfa12 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -81,13 +81,19 @@ What left the API relative to v1 — with the design that removed it: it already holds is un-shared (D5). - Tables: `users` (keyed by `publicKey`; carries quota-limit override and BYO flag), `auth_methods`, `refresh_tokens`, `accelerator_tokens`, `account_devices`, - `device_approvals`, `identity_subjects`. + `device_approvals`, `identity_subjects`, `spent_identity_tokens`. - **`identity_subjects`** maps a verified provider identity — hashed, never stored in the clear — to the stable subject id the identity token's `sub` - carries and `loginWithJWT` takes as its `verifierId`. It holds no `user_id`: + carries and `loginWithJWT` takes as its `verifierId`. A row holds the + provider kind, the SHA-256 hash of the provider identifier, and the first and + last use times, and no display form of the identifier. It holds no `user_id`: the account still materializes at `POST /auth/login` against the derived key, so this table cannot fork the account model, and linking a second method later is pointing another provider identity at an existing subject (ADR 0039). +- **`spent_identity_tokens`**: a device registration spends the identity token + it presents, and records the token's `jti` and expiry here, and nothing else. + A replay of a spent token answers 401. `POST /auth/login` and + `POST /device-approval/session` do not spend the token. ## Pin/name registry @@ -342,8 +348,8 @@ rate limiting must be verified effective in e2e); staging test hooks ## Data model (complete) -`users`, `auth_methods`, `identity_subjects`, `refresh_tokens`, -`accelerator_tokens`, `account_devices`, `device_approvals`, +`users`, `auth_methods`, `identity_subjects`, `spent_identity_tokens`, +`refresh_tokens`, `accelerator_tokens`, `account_devices`, `device_approvals`, `name_inventory (account, ipnsName)`, `pinned_cids (account, cid, size, advisory)`, `pin_references (account, ipnsName, cid)`, `mailbox_messages`,