From 5702424194b3a49f33e2e70d933ed9e46a0593bf Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 22:10:24 +0200 Subject: [PATCH 1/7] fix(api): spend the identity token at registration and drop the subject display column A device registration now spends the identity token it presents. The token carries a jti, and the registration records it in spent_identity_tokens on its own transaction, so a refused registration leaves the token unspent. A replay is refused with 401. The primary key makes two concurrent spends of one token serialize, and each spend reclaims expired rows past a one-minute grace. identity_subjects.identifier_display is dropped. No code read it, and it kept a partial email or wallet address beside an unsalted hash. The mint now writes the kind, the hash and the last-use time only. Both migrations are idempotent. The table migration takes a transaction advisory lock, so two runners that start together both succeed. --- apps/api/openapi.json | 4 +- apps/api/src/auth/auth.module.test.ts | 10 ++- apps/api/src/auth/auth.module.ts | 10 ++- .../auth/entities/identity-subject.entity.ts | 4 - .../entities/spent-identity-token.entity.ts | 20 +++++ apps/api/src/auth/identity.http.itest.ts | 15 ++++ .../services/identity-exchange.service.ts | 16 +--- .../services/identity-subject.service.test.ts | 37 ++++++--- .../auth/services/identity-subject.service.ts | 8 +- .../services/identity-token.service.test.ts | 37 ++++++++- .../auth/services/identity-token.service.ts | 63 +++++++++++++-- .../device-approval.http.itest.ts | 78 ++++++++++++++++++- .../src/device-approval/device.controller.ts | 3 +- .../dto/device-approval.dto.ts | 2 +- .../services/account-device.service.test.ts | 47 +++++++++-- .../services/account-device.service.ts | 15 ++-- ...790640000000-DropIdentitySubjectDisplay.ts | 19 +++++ .../1790640000001-AddSpentIdentityTokens.ts | 24 ++++++ apps/api/src/testing/integration-db.ts | 6 ++ blueprint/api.md | 4 +- 20 files changed, 361 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/auth/entities/spent-identity-token.entity.ts create mode 100644 apps/api/src/migrations/1790640000000-DropIdentitySubjectDisplay.ts create mode 100644 apps/api/src/migrations/1790640000001-AddSpentIdentityTokens.ts 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..c2f9a9d7ad 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -1,7 +1,7 @@ import * as jose from 'jose'; import { generateKeyPairSync } from 'node:crypto'; import { beforeEach, describe, expect, it } from 'vitest'; -import { FakeClock, fakeConfig } from '../../testing/fakes'; +import { FakeClock, fakeConfig, FakeEntropy } from '../../testing/fakes'; import { IDENTITY_TOKEN_AUDIENCE, IDENTITY_TOKEN_ISSUER, @@ -19,7 +19,7 @@ function encodedSigningKey(): string { } async function bootedService(values: Record, clock = new FakeClock()) { - const service = new IdentityTokenService(fakeConfig(values).service, clock); + const service = new IdentityTokenService(fakeConfig(values).service, clock, new FakeEntropy()); await service.onModuleInit(); return service; } @@ -109,15 +109,46 @@ 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('refuses a token that carries no token id, since no spend could record it', async () => { + const clock = new FakeClock(); + const service = await bootedService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); + const issuedAt = Math.floor(clock.now().getTime() / 1000); + const untracked = await new jose.SignJWT({ method: 'google' }) + .setProtectedHeader({ alg: 'RS256', kid: service.jwks().keys[0].kid }) + .setSubject('subject-id') + .setIssuer(IDENTITY_TOKEN_ISSUER) + .setAudience(IDENTITY_TOKEN_AUDIENCE) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + 300) + .sign(await jose.importPKCS8(privateKey, 'RS256')); + + await expect(service.verify(untracked)).rejects.toThrow(jose.errors.JWTClaimValidationFailed); + }); + 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..71abdfc5d0 100644 --- a/apps/api/src/auth/services/identity-token.service.ts +++ b/apps/api/src/auth/services/identity-token.service.ts @@ -1,12 +1,16 @@ -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'; const ALGORITHM = 'RS256'; @@ -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 { @@ -84,6 +104,7 @@ export class IdentityTokenService implements OnModuleInit { const token = await new jose.SignJWT({ method: claims.method }) .setProtectedHeader({ alg: ALGORITHM, kid: 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,46 @@ 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 }; + if (typeof tokenId !== 'string' || !UUID_RE.test(tokenId) || exp === undefined) { + throw new Error('identity token is missing its token id or expiry'); + } + return { subject, method, tokenId, expiresAt: new Date(exp * 1000) }; + } + + /** + * 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 { + // `SKIP LOCKED` yields rows a concurrent spend is already reclaiming, so two + // sweeps never wait on each other's row locks. + 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] + ); + 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'); + } } /** 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..6d15be3337 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,81 @@ 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('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..23f3464381 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 @@ -23,14 +23,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 +39,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 +51,7 @@ describe('AccountDeviceService', () => { return { publicKey: key.publicKey, signature: key.sign(deviceRegistrationPayload(signedAccount, key.publicKey)), - identityToken: token, + identityToken: mintIdentityToken(subjectOf(token)), ...overrides, }; } @@ -56,13 +60,20 @@ 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( @@ -147,6 +158,30 @@ 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('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/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/blueprint/api.md b/blueprint/api.md index 98eb098591..6ccd86d246 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -84,7 +84,9 @@ What left the API relative to v1 — with the design that removed it: `device_approvals`, `identity_subjects`. - **`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). From 1e6425e8bf4ac09c2fd7a7de8f3d61c406da957c Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 22:30:02 +0200 Subject: [PATCH 2/7] fix(web): drop the identity token once a device registration spends it The API now refuses a spent identity token. The devices pane kept the token after a registration, so a revoke and a second registration in the same sign-in got 401, and the engine reported a dead session. The web session now drops the token after a successful registration, and the pane asks the member to sign in again. Tests: a registration then a rendezvous session with the same token, a token id that is not a UUID gets 401 before any spend, and the pane closes the register control after a registration. --- .../services/identity-token.service.test.ts | 38 +++++++----------- .../device-approval.http.itest.ts | 11 ++++++ .../services/account-device.service.test.ts | 28 ++++++++++++- apps/api/src/testing/identity-tokens.ts | 39 +++++++++++++++++++ apps/web/src/auth/coreKit.ts | 6 +++ .../components/settings/DevicesPane.test.tsx | 26 +++++++++++++ apps/web/src/hooks/useDevices.ts | 3 ++ apps/web/src/test/authFakes.tsx | 3 ++ 8 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/testing/identity-tokens.ts 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 c2f9a9d7ad..f490000c6e 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -1,23 +1,13 @@ import * as jose from 'jose'; -import { generateKeyPairSync } from 'node:crypto'; import { beforeEach, describe, expect, it } from 'vitest'; import { FakeClock, fakeConfig, FakeEntropy } from '../../testing/fakes'; +import { encodedIdentitySigningKey, identityTokenWithJti } 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, new FakeEntropy()); await service.onModuleInit(); @@ -34,7 +24,7 @@ describe('IdentityTokenService', () => { let encodedPem: string; beforeEach(() => { - encodedPem = encodedSigningKey(); + 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' }); @@ -135,20 +125,22 @@ describe('IdentityTokenService', () => { { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, clock ); - const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); - const issuedAt = Math.floor(clock.now().getTime() / 1000); - const untracked = await new jose.SignJWT({ method: 'google' }) - .setProtectedHeader({ alg: 'RS256', kid: service.jwks().keys[0].kid }) - .setSubject('subject-id') - .setIssuer(IDENTITY_TOKEN_ISSUER) - .setAudience(IDENTITY_TOKEN_AUDIENCE) - .setIssuedAt(issuedAt) - .setExpirationTime(issuedAt + 300) - .sign(await jose.importPKCS8(privateKey, 'RS256')); + const untracked = await identityTokenWithJti(encodedPem, clock, undefined); await expect(service.verify(untracked)).rejects.toThrow(jose.errors.JWTClaimValidationFailed); }); + it('refuses a token whose token id is not a UUID, before any spend reads it', async () => { + const clock = new FakeClock(); + const service = await bootedService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + + const malformed = await identityTokenWithJti(encodedPem, clock, 'not-a-uuid'); + await expect(service.verify(malformed)).rejects.toThrow(/token id/); + }); + 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/device-approval/device-approval.http.itest.ts b/apps/api/src/device-approval/device-approval.http.itest.ts index 6d15be3337..27f18d0cb6 100644 --- a/apps/api/src/device-approval/device-approval.http.itest.ts +++ b/apps/api/src/device-approval/device-approval.http.itest.ts @@ -537,6 +537,17 @@ describe('device-approval HTTP surface (real Postgres)', () => { 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); 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 23f3464381..7a18ba91e4 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 @@ -6,7 +6,8 @@ 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 { FakeClock, fakeConfig, FakeEntropy } from '../../testing/fakes'; +import { encodedIdentitySigningKey, identityTokenWithJti } from '../../testing/identity-tokens'; import { deviceRegistrationPayload } from '../device-signature'; import { AccountDevice } from '../entities/account-device.entity'; import { AccountDeviceService, RegisterDeviceInput } from './account-device.service'; @@ -182,6 +183,31 @@ describe('AccountDeviceService', () => { ).rejects.toBeInstanceOf(UnauthorizedException); }); + it('refuses a token whose token id is not a UUID with 401, before the uuid column sees it', async () => { + const encodedPem = encodedIdentitySigningKey(); + const realTokens = new IdentityTokenService( + fakeConfig({ NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }).service, + clock, + new FakeEntropy() + ); + await realTokens.onModuleInit(); + const spend = vi.spyOn(realTokens, 'spend'); + service = new AccountDeviceService( + devices as never, + new FakeDataSource(devices as never) as never, + realTokens, + clock, + fakeConfig({}).service + ); + + 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('is idempotent per key: a re-registration updates rather than duplicates', async () => { const first = await service.register( account, diff --git a/apps/api/src/testing/identity-tokens.ts b/apps/api/src/testing/identity-tokens.ts new file mode 100644 index 0000000000..f96aebc01d --- /dev/null +++ b/apps/api/src/testing/identity-tokens.ts @@ -0,0 +1,39 @@ +import * as jose from 'jose'; +import { generateKeyPairSync } from 'node:crypto'; +import { + IDENTITY_TOKEN_AUDIENCE, + IDENTITY_TOKEN_ISSUER, +} from '../auth/services/identity-token.service'; +import { FakeClock } from './fakes'; + +/** 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'); +} + +/** + * An identity token under the configured key whose `jti` the caller picks, or + * omits: the shapes the API's own mint never produces. + */ +export async function identityTokenWithJti( + encodedPem: string, + clock: FakeClock, + jti: string | undefined +): Promise { + const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); + const issuedAt = Math.floor(clock.now().getTime() / 1000); + const builder = new jose.SignJWT({ method: 'google' }) + .setProtectedHeader({ alg: 'RS256', kid: 'cipherbox-identity-1' }) + .setSubject('subject-id') + .setIssuer(IDENTITY_TOKEN_ISSUER) + .setAudience(IDENTITY_TOKEN_AUDIENCE) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + 300); + if (jti !== undefined) builder.setJti(jti); + return builder.sign(await jose.importPKCS8(privateKey, 'RS256')); +} diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts index c4e31163b2..70841cd8bc 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. */ + spendIdentityToken(): 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; } + spendIdentityToken(): 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..acd4350bd7 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?.spendIdentityToken(); setThisDevice(publicKey); await read(facade); }), diff --git a/apps/web/src/test/authFakes.tsx b/apps/web/src/test/authFakes.tsx index 6c06645986..e0515b174c 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, + spendIdentityToken() { + identityToken = null; + }, mintApprovalFactor() { const key = new Uint8Array(32).fill(0x5a); calls.mintedFactors.push(key); From 49e5febf8c5d7f43f2e2dcae34295d7d3baa5dce Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 22:44:31 +0200 Subject: [PATCH 3/7] refactor(api): spend the identity token before the sweep and share the token test setup A replayed token is refused by the insert, so it no longer pays for a sweep that its transaction rolls back. The two statements are unchanged. The test suites share one booted token service helper, and a new unit test covers a token that carries no expiry. --- .../services/identity-token.service.test.ts | 42 ++++++++++++------- .../auth/services/identity-token.service.ts | 16 +++---- .../services/account-device.service.test.ts | 33 ++++++++------- apps/api/src/testing/identity-tokens.ts | 23 +++++++--- 4 files changed, 70 insertions(+), 44 deletions(-) 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 f490000c6e..c872c9e751 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -1,19 +1,18 @@ import * as jose from 'jose'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { FakeClock, fakeConfig, FakeEntropy } from '../../testing/fakes'; -import { encodedIdentitySigningKey, identityTokenWithJti } from '../../testing/identity-tokens'; +import { randomUUID } from 'node:crypto'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { FakeClock } from '../../testing/fakes'; +import { + bootedIdentityTokenService as bootedService, + encodedIdentitySigningKey, + identityTokenWithJti, +} from '../../testing/identity-tokens'; import { IDENTITY_TOKEN_AUDIENCE, IDENTITY_TOKEN_ISSUER, IdentityTokenService, } from './identity-token.service'; -async function bootedService(values: Record, clock = new FakeClock()) { - const service = new IdentityTokenService(fakeConfig(values).service, clock, new FakeEntropy()); - 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; @@ -23,7 +22,7 @@ async function verificationKeyFrom(service: IdentityTokenService) { describe('IdentityTokenService', () => { let encodedPem: string; - beforeEach(() => { + beforeAll(() => { encodedPem = encodedIdentitySigningKey(); }); @@ -119,26 +118,37 @@ describe('IdentityTokenService', () => { expect(first.tokenId).not.toBe(second.tokenId); }); - it('refuses a token that carries no token id, since no spend could record it', async () => { + 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 untracked = await identityTokenWithJti(encodedPem, clock, undefined); - await expect(service.verify(untracked)).rejects.toThrow(jose.errors.JWTClaimValidationFailed); + const token = await identityTokenWithJti(encodedPem, clock, jti); + await expect(service.verify(token)).rejects.toThrow(refusal); }); - it('refuses a token whose token id is not a UUID, before any spend reads it', async () => { + 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 malformed = await identityTokenWithJti(encodedPem, clock, 'not-a-uuid'); - await expect(service.verify(malformed)).rejects.toThrow(/token id/); + const unbounded = await identityTokenWithJti(encodedPem, clock, randomUUID(), 'omitted'); + await expect(service.verify(unbounded)).rejects.toThrow(jose.errors.JWTClaimValidationFailed); }); it('expires the token on the injected clock, not the wall clock', async () => { diff --git a/apps/api/src/auth/services/identity-token.service.ts b/apps/api/src/auth/services/identity-token.service.ts index 71abdfc5d0..ba528e49af 100644 --- a/apps/api/src/auth/services/identity-token.service.ts +++ b/apps/api/src/auth/services/identity-token.service.ts @@ -148,14 +148,6 @@ export class IdentityTokenService implements OnModuleInit { * concurrent spend of the same token wait for this one and then refuse. */ async spend(manager: EntityManager, token: VerifiedIdentityToken): Promise { - // `SKIP LOCKED` yields rows a concurrent spend is already reclaiming, so two - // sweeps never wait on each other's row locks. - 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] - ); const inserted = await manager .createQueryBuilder() .insert() @@ -167,6 +159,14 @@ export class IdentityTokenService implements OnModuleInit { 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] + ); } /** 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 7a18ba91e4..e162c50384 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 @@ -6,8 +6,12 @@ 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, FakeEntropy } from '../../testing/fakes'; -import { encodedIdentitySigningKey, identityTokenWithJti } from '../../testing/identity-tokens'; +import { FakeClock, fakeConfig } from '../../testing/fakes'; +import { + bootedIdentityTokenService, + encodedIdentitySigningKey, + identityTokenWithJti, +} from '../../testing/identity-tokens'; import { deviceRegistrationPayload } from '../device-signature'; import { AccountDevice } from '../entities/account-device.entity'; import { AccountDeviceService, RegisterDeviceInput } from './account-device.service'; @@ -77,7 +81,14 @@ describe('AccountDeviceService', () => { 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, @@ -185,20 +196,12 @@ describe('AccountDeviceService', () => { it('refuses a token whose token id is not a UUID with 401, before the uuid column sees it', async () => { const encodedPem = encodedIdentitySigningKey(); - const realTokens = new IdentityTokenService( - fakeConfig({ NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }).service, - clock, - new FakeEntropy() + const realTokens = await bootedIdentityTokenService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock ); - await realTokens.onModuleInit(); const spend = vi.spyOn(realTokens, 'spend'); - service = new AccountDeviceService( - devices as never, - new FakeDataSource(devices as never) as never, - realTokens, - clock, - fakeConfig({}).service - ); + service = serviceOver(realTokens); const malformed = await identityTokenWithJti(encodedPem, clock, 'not-a-uuid'); await expect( diff --git a/apps/api/src/testing/identity-tokens.ts b/apps/api/src/testing/identity-tokens.ts index f96aebc01d..c31209930c 100644 --- a/apps/api/src/testing/identity-tokens.ts +++ b/apps/api/src/testing/identity-tokens.ts @@ -3,8 +3,9 @@ import { generateKeyPairSync } from 'node:crypto'; import { IDENTITY_TOKEN_AUDIENCE, IDENTITY_TOKEN_ISSUER, + IdentityTokenService, } from '../auth/services/identity-token.service'; -import { FakeClock } from './fakes'; +import { FakeClock, fakeConfig, FakeEntropy } from './fakes'; /** A base64-encoded PKCS8 PEM, exactly as `IDENTITY_JWT_PRIVATE_KEY` carries it. */ export function encodedIdentitySigningKey(): string { @@ -16,14 +17,26 @@ export function encodedIdentitySigningKey(): string { 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: the shapes the API's own mint never produces. + * 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 + jti: string | undefined, + expiry: 'stamped' | 'omitted' = 'stamped' ): Promise { const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); const issuedAt = Math.floor(clock.now().getTime() / 1000); @@ -32,8 +45,8 @@ export async function identityTokenWithJti( .setSubject('subject-id') .setIssuer(IDENTITY_TOKEN_ISSUER) .setAudience(IDENTITY_TOKEN_AUDIENCE) - .setIssuedAt(issuedAt) - .setExpirationTime(issuedAt + 300); + .setIssuedAt(issuedAt); + if (expiry === 'stamped') builder.setExpirationTime(issuedAt + 300); if (jti !== undefined) builder.setJti(jti); return builder.sign(await jose.importPKCS8(privateKey, 'RS256')); } From c51772c836095909a730fc64de09759bb703fc0c Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 22:54:27 +0200 Subject: [PATCH 4/7] fix(api): refuse an identity token whose expiry is not finite jose accepts an exp such as 1e999, which parses to Infinity, and 1e300, which is finite but past the Date range. Both gave an invalid date for the spend row. verify now refuses any exp that gives no valid instant, with the same refusal as a bad jti, so the endpoint answers 401. The blueprint table list in "Identity and auth" now names spent_identity_tokens. --- .../services/identity-token.service.test.ts | 27 +++++++++++++++++++ .../auth/services/identity-token.service.ts | 13 ++++++--- .../services/account-device.service.test.ts | 18 +++++++++++++ apps/api/src/testing/identity-tokens.ts | 22 ++++++++++++++- blueprint/api.md | 2 +- 5 files changed, 77 insertions(+), 5 deletions(-) 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 c872c9e751..9bfda1438d 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -6,6 +6,7 @@ import { bootedIdentityTokenService as bootedService, encodedIdentitySigningKey, identityTokenWithJti, + identityTokenWithRawExp, } from '../../testing/identity-tokens'; import { IDENTITY_TOKEN_AUDIENCE, @@ -151,6 +152,32 @@ describe('IdentityTokenService', () => { 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(identityTokenWithRawExp(encodedPem, String(exp))); + expect(verified.expiresAt).toEqual(new Date(exp * 1000)); + }); + + // `1e999` parses to Infinity, and `1e300` is finite but past the Date range: + // either would put an invalid date on the spend row. + it.each(['1e999', '-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(identityTokenWithRawExp(encodedPem, exp))).rejects.toThrow(); + } + ); + 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 ba528e49af..5f6d0c51c2 100644 --- a/apps/api/src/auth/services/identity-token.service.ts +++ b/apps/api/src/auth/services/identity-token.service.ts @@ -136,10 +136,17 @@ export class IdentityTokenService implements OnModuleInit { if (typeof subject !== 'string' || !isIdentitySubjectKind(method)) { throw new Error('identity token is missing its subject or method claim'); } - if (typeof tokenId !== 'string' || !UUID_RE.test(tokenId) || exp === undefined) { - throw new Error('identity token is missing its token id or expiry'); + // `jose` accepts an `exp` such as `1e999` (Infinity) or `1e300`, which is no + // valid instant for the spend row. + 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: new Date(exp * 1000) }; + return { subject, method, tokenId, expiresAt }; } /** 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 e162c50384..3fb5655800 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 @@ -11,6 +11,7 @@ import { bootedIdentityTokenService, encodedIdentitySigningKey, identityTokenWithJti, + identityTokenWithRawExp, } from '../../testing/identity-tokens'; import { deviceRegistrationPayload } from '../device-signature'; import { AccountDevice } from '../entities/account-device.entity'; @@ -211,6 +212,23 @@ describe('AccountDeviceService', () => { expect(devices.rows).toHaveLength(0); }); + it('refuses a token whose expiry is not finite with 401, before any spend', async () => { + const encodedPem = encodedIdentitySigningKey(); + const realTokens = await bootedIdentityTokenService( + { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, + clock + ); + const spend = vi.spyOn(realTokens, 'spend'); + service = serviceOver(realTokens); + + const unbounded = 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/testing/identity-tokens.ts b/apps/api/src/testing/identity-tokens.ts index c31209930c..c116962d11 100644 --- a/apps/api/src/testing/identity-tokens.ts +++ b/apps/api/src/testing/identity-tokens.ts @@ -1,5 +1,5 @@ import * as jose from 'jose'; -import { generateKeyPairSync } from 'node:crypto'; +import { createSign, generateKeyPairSync, randomUUID } from 'node:crypto'; import { IDENTITY_TOKEN_AUDIENCE, IDENTITY_TOKEN_ISSUER, @@ -50,3 +50,23 @@ export async function identityTokenWithJti( if (jti !== undefined) builder.setJti(jti); return builder.sign(await jose.importPKCS8(privateKey, 'RS256')); } + +/** + * An identity token under the configured key whose `exp` is the raw JSON number + * `expJson`, such as `1e999`. Signed by hand, since `SignJWT` refuses such values. + */ +export function identityTokenWithRawExp(encodedPem: string, expJson: string): string { + const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); + const header = Buffer.from(JSON.stringify({ alg: 'RS256', kid: 'cipherbox-identity-1' })); + 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}}`); + const signingInput = `${header.toString('base64url')}.${payload.toString('base64url')}`; + const signature = createSign('RSA-SHA256').update(signingInput).sign(privateKey); + return `${signingInput}.${signature.toString('base64url')}`; +} diff --git a/blueprint/api.md b/blueprint/api.md index 6ccd86d246..7d8c7d6c7d 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -81,7 +81,7 @@ 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`. A row holds the From fc7dc479568efb8afdf6e3c367b2bfb6627742bf Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 23:06:57 +0200 Subject: [PATCH 5/7] refactor(api): tighten the identity token expiry tests and their helper Sign the raw-expiry test token with jose CompactSign, share one real token service setup and one key across the two register tests, assert the refusal that each expiry value meets, and state the verify comment for every caller. --- .../services/identity-token.service.test.ts | 30 ++++++++-------- .../auth/services/identity-token.service.ts | 3 +- .../services/account-device.service.test.ts | 36 ++++++++++--------- apps/api/src/testing/identity-tokens.ts | 16 +++++---- 4 files changed, 46 insertions(+), 39 deletions(-) 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 9bfda1438d..2c2809edba 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -160,23 +160,25 @@ describe('IdentityTokenService', () => { ); const exp = Math.floor(clock.now().getTime() / 1000) + 300; - const verified = await service.verify(identityTokenWithRawExp(encodedPem, String(exp))); + const verified = await service.verify(await identityTokenWithRawExp(encodedPem, String(exp))); expect(verified.expiresAt).toEqual(new Date(exp * 1000)); }); - // `1e999` parses to Infinity, and `1e300` is finite but past the Date range: - // either would put an invalid date on the spend row. - it.each(['1e999', '-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(identityTokenWithRawExp(encodedPem, exp))).rejects.toThrow(); - } - ); + // `jose` refuses `-1e999` as expired; the other two reach the date check. + it.each([ + ['1e999', 'valid expiry'], + ['-1e999', jose.errors.JWTExpired], + ['1e300', 'valid expiry'], + ])('refuses a token whose expiry %s is no valid instant', async (exp, refusal) => { + const service = await bootedService({ + NODE_ENV: 'production', + IDENTITY_JWT_PRIVATE_KEY: encodedPem, + }); + + await expect(service.verify(await identityTokenWithRawExp(encodedPem, exp))).rejects.toThrow( + refusal + ); + }); it('expires the token on the injected clock, not the wall clock', async () => { const clock = new FakeClock(); diff --git a/apps/api/src/auth/services/identity-token.service.ts b/apps/api/src/auth/services/identity-token.service.ts index 5f6d0c51c2..0bff17469e 100644 --- a/apps/api/src/auth/services/identity-token.service.ts +++ b/apps/api/src/auth/services/identity-token.service.ts @@ -136,8 +136,7 @@ export class IdentityTokenService implements OnModuleInit { if (typeof subject !== 'string' || !isIdentitySubjectKind(method)) { throw new Error('identity token is missing its subject or method claim'); } - // `jose` accepts an `exp` such as `1e999` (Infinity) or `1e300`, which is no - // valid instant for the spend row. + // `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' || 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 3fb5655800..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,7 +1,7 @@ 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'; @@ -106,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, @@ -196,13 +212,7 @@ describe('AccountDeviceService', () => { }); it('refuses a token whose token id is not a UUID with 401, before the uuid column sees it', async () => { - const encodedPem = encodedIdentitySigningKey(); - const realTokens = await bootedIdentityTokenService( - { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, - clock - ); - const spend = vi.spyOn(realTokens, 'spend'); - service = serviceOver(realTokens); + const spend = await overRealTokens(); const malformed = await identityTokenWithJti(encodedPem, clock, 'not-a-uuid'); await expect( @@ -213,15 +223,9 @@ describe('AccountDeviceService', () => { }); it('refuses a token whose expiry is not finite with 401, before any spend', async () => { - const encodedPem = encodedIdentitySigningKey(); - const realTokens = await bootedIdentityTokenService( - { NODE_ENV: 'production', IDENTITY_JWT_PRIVATE_KEY: encodedPem }, - clock - ); - const spend = vi.spyOn(realTokens, 'spend'); - service = serviceOver(realTokens); + const spend = await overRealTokens(); - const unbounded = identityTokenWithRawExp(encodedPem, '1e999'); + const unbounded = await identityTokenWithRawExp(encodedPem, '1e999'); await expect( service.register(account, registration(device, account, { identityToken: unbounded })) ).rejects.toBeInstanceOf(UnauthorizedException); diff --git a/apps/api/src/testing/identity-tokens.ts b/apps/api/src/testing/identity-tokens.ts index c116962d11..b67481aeed 100644 --- a/apps/api/src/testing/identity-tokens.ts +++ b/apps/api/src/testing/identity-tokens.ts @@ -1,5 +1,5 @@ import * as jose from 'jose'; -import { createSign, generateKeyPairSync, randomUUID } from 'node:crypto'; +import { generateKeyPairSync, randomUUID } from 'node:crypto'; import { IDENTITY_TOKEN_AUDIENCE, IDENTITY_TOKEN_ISSUER, @@ -53,11 +53,13 @@ export async function identityTokenWithJti( /** * An identity token under the configured key whose `exp` is the raw JSON number - * `expJson`, such as `1e999`. Signed by hand, since `SignJWT` refuses such values. + * `expJson`, such as `1e999`. Signed as raw bytes, since `SignJWT` refuses such values. */ -export function identityTokenWithRawExp(encodedPem: string, expJson: string): string { +export async function identityTokenWithRawExp( + encodedPem: string, + expJson: string +): Promise { const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); - const header = Buffer.from(JSON.stringify({ alg: 'RS256', kid: 'cipherbox-identity-1' })); const claims = JSON.stringify({ iss: IDENTITY_TOKEN_ISSUER, aud: IDENTITY_TOKEN_AUDIENCE, @@ -66,7 +68,7 @@ export function identityTokenWithRawExp(encodedPem: string, expJson: string): st jti: randomUUID(), }); const payload = Buffer.from(`${claims.slice(0, -1)},"exp":${expJson}}`); - const signingInput = `${header.toString('base64url')}.${payload.toString('base64url')}`; - const signature = createSign('RSA-SHA256').update(signingInput).sign(privateKey); - return `${signingInput}.${signature.toString('base64url')}`; + return new jose.CompactSign(payload) + .setProtectedHeader({ alg: 'RS256', kid: 'cipherbox-identity-1' }) + .sign(await jose.importPKCS8(privateKey, 'RS256')); } From 6c823601b469545e8ce7f012ff8aee583da5d36c Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 23:36:15 +0200 Subject: [PATCH 6/7] refactor(api): state the spent token rule and share the test signing key The blueprint now states what spent_identity_tokens holds and which routes spend the identity token. The test helpers share one signing key import and one header, which reads the key id the service exports. The web session method is dropIdentityToken, since the API does the spend. The expiry tests keep a past non-finite exp apart from the invalid instants. --- .../services/identity-token.service.test.ts | 27 ++++++++++++------- .../auth/services/identity-token.service.ts | 11 +++++--- apps/api/src/testing/identity-tokens.ts | 18 ++++++++----- apps/web/src/auth/coreKit.ts | 4 +-- apps/web/src/hooks/useDevices.ts | 2 +- apps/web/src/test/authFakes.tsx | 2 +- blueprint/api.md | 4 +++ 7 files changed, 46 insertions(+), 22 deletions(-) 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 2c2809edba..503f338343 100644 --- a/apps/api/src/auth/services/identity-token.service.test.ts +++ b/apps/api/src/auth/services/identity-token.service.test.ts @@ -164,20 +164,29 @@ describe('IdentityTokenService', () => { expect(verified.expiresAt).toEqual(new Date(exp * 1000)); }); - // `jose` refuses `-1e999` as expired; the other two reach the date check. - it.each([ - ['1e999', 'valid expiry'], - ['-1e999', jose.errors.JWTExpired], - ['1e300', 'valid expiry'], - ])('refuses a token whose expiry %s is no valid instant', async (exp, refusal) => { + 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, exp))).rejects.toThrow( - refusal - ); + 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 () => { diff --git a/apps/api/src/auth/services/identity-token.service.ts b/apps/api/src/auth/services/identity-token.service.ts index 0bff17469e..ff269c94a1 100644 --- a/apps/api/src/auth/services/identity-token.service.ts +++ b/apps/api/src/auth/services/identity-token.service.ts @@ -12,7 +12,7 @@ import { } 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. */ @@ -102,7 +102,7 @@ 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) @@ -180,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/testing/identity-tokens.ts b/apps/api/src/testing/identity-tokens.ts index b67481aeed..aa2fd2be20 100644 --- a/apps/api/src/testing/identity-tokens.ts +++ b/apps/api/src/testing/identity-tokens.ts @@ -3,10 +3,18 @@ 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', { @@ -38,17 +46,16 @@ export async function identityTokenWithJti( jti: string | undefined, expiry: 'stamped' | 'omitted' = 'stamped' ): Promise { - const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); const issuedAt = Math.floor(clock.now().getTime() / 1000); const builder = new jose.SignJWT({ method: 'google' }) - .setProtectedHeader({ alg: 'RS256', kid: 'cipherbox-identity-1' }) + .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 jose.importPKCS8(privateKey, 'RS256')); + return builder.sign(await identitySigningKey(encodedPem)); } /** @@ -59,7 +66,6 @@ export async function identityTokenWithRawExp( encodedPem: string, expJson: string ): Promise { - const privateKey = Buffer.from(encodedPem, 'base64').toString('utf8'); const claims = JSON.stringify({ iss: IDENTITY_TOKEN_ISSUER, aud: IDENTITY_TOKEN_AUDIENCE, @@ -69,6 +75,6 @@ export async function identityTokenWithRawExp( }); const payload = Buffer.from(`${claims.slice(0, -1)},"exp":${expJson}}`); return new jose.CompactSign(payload) - .setProtectedHeader({ alg: 'RS256', kid: 'cipherbox-identity-1' }) - .sign(await jose.importPKCS8(privateKey, 'RS256')); + .setProtectedHeader(HEADER) + .sign(await identitySigningKey(encodedPem)); } diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts index 70841cd8bc..263a6c49b1 100644 --- a/apps/web/src/auth/coreKit.ts +++ b/apps/web/src/auth/coreKit.ts @@ -63,7 +63,7 @@ export interface WebCoreKitSession extends CoreKitSession { /** 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. */ - spendIdentityToken(): void; + 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. @@ -383,7 +383,7 @@ class Web3AuthSession implements WebCoreKitSession { return this.signedInToken; } - spendIdentityToken(): void { + dropIdentityToken(): void { this.signedInToken = null; } diff --git a/apps/web/src/hooks/useDevices.ts b/apps/web/src/hooks/useDevices.ts index acd4350bd7..95c84ff328 100644 --- a/apps/web/src/hooks/useDevices.ts +++ b/apps/web/src/hooks/useDevices.ts @@ -78,7 +78,7 @@ export function useDevices(): DevicesRead { 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?.spendIdentityToken(); + session?.dropIdentityToken(); setThisDevice(publicKey); await read(facade); }), diff --git a/apps/web/src/test/authFakes.tsx b/apps/web/src/test/authFakes.tsx index e0515b174c..e66cd7aabb 100644 --- a/apps/web/src/test/authFakes.tsx +++ b/apps/web/src/test/authFakes.tsx @@ -558,7 +558,7 @@ export function fakeCoreKitSession( forgetDevice: () => Promise.resolve(), deviceIdentity: () => (options.noDeviceIdentity === true ? null : device), identityToken: () => identityToken, - spendIdentityToken() { + dropIdentityToken() { identityToken = null; }, mintApprovalFactor() { diff --git a/blueprint/api.md b/blueprint/api.md index 7d8c7d6c7d..55bf7b516f 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -90,6 +90,10 @@ What left the API relative to v1 — with the design that removed it: 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 From 822ad330067e595d715fcf1a3bc0ca228b1d3887 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 30 Sep 2026 01:31:15 +0200 Subject: [PATCH 7/7] docs(blueprint): name spent_identity_tokens in the complete data model The "Data model (complete)" list says "Nothing else", but it did not name the table that this change adds. --- blueprint/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blueprint/api.md b/blueprint/api.md index 55bf7b516f..85747cfa12 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -348,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`,