Skip to content

Commit 26c6204

Browse files
committed
perf: Add apiKey fingerprint for lookups
1 parent 0cf7b1e commit 26c6204

8 files changed

Lines changed: 144 additions & 49 deletions

File tree

src/controllers/admin/api-key.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -391,27 +391,28 @@ export class APIKeyController {
391391
} satisfies APIKeyGetResponseBody);
392392
}
393393
// Otherwise try to get the latest not revoked API key
394-
const keys = await APIKeyService.instance.find(
394+
const key = await APIKeyService.instance.findOne(
395395
{
396396
customer: response.locals.customer,
397397
revoked: false,
398398
},
399+
undefined,
400+
options,
399401
{
400402
createdAt: 'DESC',
401-
},
402-
options
403+
}
403404
);
404-
if (keys.length == 0) {
405+
if (!key) {
405406
return response.status(StatusCodes.NOT_FOUND).json({
406407
error: 'API key not found',
407408
});
408409
}
409410
return response.status(StatusCodes.OK).json({
410-
apiKey: keys[0].apiKey,
411-
name: keys[0].name,
412-
createdAt: keys[0].createdAt.toISOString(),
413-
expiresAt: keys[0].expiresAt.toISOString(),
414-
revoked: keys[0].revoked,
411+
apiKey: key.apiKey,
412+
name: key.name,
413+
createdAt: key.createdAt.toISOString(),
414+
expiresAt: key.expiresAt.toISOString(),
415+
revoked: key.revoked,
415416
} satisfies APIKeyGetResponseBody);
416417
} catch (error) {
417418
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({

src/database/entities/api.key.entity.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BeforeInsert, BeforeUpdate, Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
1+
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
22

33
import * as dotenv from 'dotenv';
44
import { CustomerEntity } from './customer.entity.js';
@@ -58,6 +58,13 @@ export class APIKeyEntity {
5858
@JoinColumn({ name: 'userId' })
5959
user!: UserEntity;
6060

61+
@Index({ unique: true })
62+
@Column({
63+
type: 'varchar',
64+
nullable: false,
65+
})
66+
fingerprint!: string;
67+
6168
@BeforeInsert()
6269
setCreatedAt() {
6370
this.createdAt = new Date();
@@ -79,7 +86,8 @@ export class APIKeyEntity {
7986
expiresAt: Date,
8087
customer: CustomerEntity,
8188
user: UserEntity,
82-
revoked = false
89+
revoked = false,
90+
fingerprint: string
8391
) {
8492
this.apiKeyHash = apiKeyHash;
8593
this.apiKey = apiKey;
@@ -88,5 +96,6 @@ export class APIKeyEntity {
8896
this.customer = customer;
8997
this.user = user;
9098
this.revoked = revoked;
99+
this.fingerprint = fingerprint;
91100
}
92101
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { TableColumn, type MigrationInterface, type QueryRunner } from 'typeorm';
2+
import { SecretBox } from '@veramo/kms-local';
3+
import { sha256 } from '../../utils'; // ensure this returns a hex string
4+
5+
export class InsertFingerprintAPIKeyTable1746780465032 implements MigrationInterface {
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
const tableName = 'apiKey';
8+
const secretBox = new SecretBox(process.env.EXTERNAL_DB_ENCRYPTION_KEY);
9+
10+
// Step 1: Delete all revoked API keys
11+
await queryRunner.query(`DELETE FROM "${tableName}" WHERE "revoked" = true`);
12+
13+
// Step 2: Add the fingerprint column (non-nullable, no default)
14+
await queryRunner.addColumn(
15+
tableName,
16+
new TableColumn({
17+
name: 'fingerprint',
18+
type: 'varchar',
19+
isNullable: false,
20+
})
21+
);
22+
23+
// Step 3: Add fingerprints for non-revoked keys
24+
const records: any[] = await queryRunner.query(`SELECT "apiKey", "apiKeyHash" FROM "${tableName}"`);
25+
for (const record of records) {
26+
const decryptedKey = await secretBox.decrypt(record.apiKey);
27+
const fingerprint = sha256(decryptedKey);
28+
await queryRunner.query(`UPDATE "${tableName}" SET "fingerprint" = $1 WHERE "apiKeyHash" = $2`, [
29+
fingerprint,
30+
record.apiKeyHash,
31+
]);
32+
}
33+
34+
// Step 4: Add a unique index on fingerprint
35+
await queryRunner.query(
36+
`CREATE UNIQUE INDEX "IDX_apiKey_c5fdf6760b38094e0905ac85e4" ON "${tableName}" ("fingerprint") `
37+
);
38+
}
39+
40+
public async down(queryRunner: QueryRunner): Promise<void> {
41+
await queryRunner.query(`DROP INDEX "IDX_apiKey_c5fdf6760b38094e0905ac85e4"`);
42+
await queryRunner.dropColumn('apiKey', 'fingerprint');
43+
}
44+
}

src/services/admin/api-key.ts

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import type { Repository } from 'typeorm';
1+
import type { FindOptionsOrder, FindOptionsRelations, FindOptionsWhere, Repository } from 'typeorm';
2+
import type { CustomerEntity } from '../../database/entities/customer.entity.js';
3+
import type { UserEntity } from '../../database/entities/user.entity.js';
4+
import type { APIServiceOptions } from '../../types/admin.js';
25
import { decodeJWT } from 'did-jwt';
36
import bcrypt from 'bcrypt';
47
import { randomBytes, createHmac } from 'crypto';
8+
import { SecretBox } from '@veramo/kms-local';
59
import { Connection } from '../../database/connection/connection.js';
6-
7-
import * as dotenv from 'dotenv';
8-
import type { CustomerEntity } from '../../database/entities/customer.entity.js';
910
import { APIKeyEntity } from '../../database/entities/api.key.entity.js';
10-
import type { UserEntity } from '../../database/entities/user.entity.js';
11-
import { SecretBox } from '@veramo/kms-local';
1211
import { API_SECRET_KEY_LENGTH, API_KEY_PREFIX, API_KEY_EXPIRATION } from '../../types/constants.js';
13-
import type { APIServiceOptions } from '../../types/admin.js';
12+
import { sha256 } from '../../utils/index.js';
13+
import * as dotenv from 'dotenv';
1414
dotenv.config();
1515

1616
export class APIKeyService {
@@ -35,11 +35,17 @@ export class APIKeyService {
3535
revoked = false,
3636
options?: APIServiceOptions
3737
): Promise<APIKeyEntity> {
38-
const apiKeyHash = await APIKeyService.hashAPIKey(apiKey);
3938
const { decryptionNeeded } = options || {};
4039
if (!apiKey) {
4140
throw new Error('API key is not specified');
4241
}
42+
43+
// fingerprint
44+
const fingerprint = sha256(apiKey);
45+
46+
// slow - hash
47+
const apiKeyHash = await APIKeyService.hashAPIKey(apiKey);
48+
4349
if (!name) {
4450
throw new Error('API key name is not specified');
4551
}
@@ -50,7 +56,10 @@ export class APIKeyService {
5056
expiresAt = new Date();
5157
expiresAt.setMonth(expiresAt.getDay() + API_KEY_EXPIRATION);
5258
}
59+
60+
// encrypt the key
5361
const encryptedAPIKey = await this.encryptAPIKey(apiKey);
62+
5463
// Create entity
5564
const apiKeyEntity = new APIKeyEntity(
5665
apiKeyHash,
@@ -59,7 +68,8 @@ export class APIKeyService {
5968
expiresAt,
6069
user.customer,
6170
user,
62-
revoked
71+
revoked,
72+
fingerprint
6373
);
6474
const apiKeyRecord = (await this.apiKeyRepository.insert(apiKeyEntity)).identifiers[0];
6575
if (!apiKeyRecord) throw new Error(`Cannot create a new API key`);
@@ -131,31 +141,45 @@ export class APIKeyService {
131141
}
132142

133143
public async get(apiKey: string, options?: APIServiceOptions) {
134-
const { decryptionNeeded } = options || {};
144+
// fingerprint
145+
const fingerprint = sha256(apiKey);
135146

136-
// ToDo: possible bottleneck cause we are fetching all the keys
137-
for (const record of await this.find({})) {
138-
if (await APIKeyService.compareAPIKey(apiKey, record.apiKeyHash)) {
139-
if (decryptionNeeded) {
140-
record.apiKey = await this.decryptAPIKey(record.apiKey);
141-
}
142-
return record;
143-
}
147+
// fetch the api key entity
148+
const apiKeyEntity = await APIKeyService.instance.findOne(
149+
{
150+
fingerprint,
151+
},
152+
{ customer: true, user: true },
153+
options
154+
);
155+
if (!apiKeyEntity) {
156+
throw new Error('Invalid API key');
157+
}
158+
159+
// validate expiry
160+
if (apiKeyEntity.revoked) {
161+
throw new Error('API Key is expired');
144162
}
145-
return null;
163+
164+
// bcrypt comparison
165+
const isValid = await APIKeyService.compareAPIKey(apiKey, apiKeyEntity.apiKeyHash);
166+
if (!isValid) throw new Error('Invalid API key');
167+
168+
return apiKeyEntity;
146169
}
147170

148171
public async find(
149-
where: Record<string, unknown>,
150-
order?: Record<string, 'ASC' | 'DESC'>,
151-
options?: APIServiceOptions
172+
where: FindOptionsWhere<APIKeyEntity>,
173+
relations?: FindOptionsRelations<APIKeyEntity>,
174+
options?: APIServiceOptions,
175+
order?: FindOptionsOrder<APIKeyEntity>
152176
) {
153177
try {
154178
const { decryptionNeeded } = options || {};
155179
const apiKeyList = await this.apiKeyRepository.find({
156-
where: where,
157-
relations: ['customer', 'user'],
158-
order: order,
180+
where,
181+
relations,
182+
order,
159183
});
160184
if (decryptionNeeded) {
161185
for (const apiKey of apiKeyList) {
@@ -168,6 +192,25 @@ export class APIKeyService {
168192
}
169193
}
170194

195+
public async findOne(
196+
where: FindOptionsWhere<APIKeyEntity>,
197+
relations?: FindOptionsRelations<APIKeyEntity>,
198+
options?: APIServiceOptions,
199+
order?: FindOptionsOrder<APIKeyEntity>
200+
) {
201+
const apiKeyEntity = await this.apiKeyRepository.findOne({
202+
where,
203+
relations,
204+
order,
205+
});
206+
207+
if (apiKeyEntity && options?.decryptionNeeded) {
208+
apiKeyEntity.apiKey = await this.decryptAPIKey(apiKeyEntity.apiKey);
209+
}
210+
211+
return apiKeyEntity;
212+
}
213+
171214
// Utils
172215
public static generateAPIKey(userId: string): string {
173216
const apiKey = createHmac('sha512', randomBytes(API_SECRET_KEY_LENGTH).toString('hex'))

src/services/identity/abstract.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export abstract class AbstractIdentityService implements IIdentityService {
229229
updateAPIKey(apiKey: APIKeyEntity, newApiKey: string): Promise<APIKeyEntity> {
230230
throw new Error(`Not supported`);
231231
}
232-
getAPIKey(customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity | undefined> {
232+
getAPIKey(customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity | null> {
233233
throw new Error(`Not supported`);
234234
}
235235
decryptAPIKey(apiKey: string): Promise<string> {

src/services/identity/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export interface IIdentityService {
171171
): Promise<UnsuspensionResult | BulkUnsuspensionResult>;
172172
setAPIKey(apiKey: string, customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity>;
173173
updateAPIKey(apiKey: APIKeyEntity, newApiKey: string): Promise<APIKeyEntity>;
174-
getAPIKey(customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity | undefined>;
174+
getAPIKey(customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity | null>;
175175
decryptAPIKey(apiKey: string): Promise<string>;
176176
}
177177

src/services/identity/postgres.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -579,22 +579,14 @@ export class PostgresIdentityService extends DefaultIdentityService {
579579
return apiKeyEntity;
580580
}
581581

582-
async getAPIKey(customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity | undefined> {
582+
async getAPIKey(customer: CustomerEntity, user: UserEntity): Promise<APIKeyEntity | null> {
583583
const options = { decryptionNeeded: true } satisfies APIServiceOptions;
584-
const keys = await APIKeyService.instance.find(
584+
const key = await APIKeyService.instance.findOne(
585585
{ customer: customer, user: user, revoked: false, name: 'idToken' },
586586
undefined,
587587
options
588588
);
589-
if (keys.length > 1) {
590-
throw new Error(
591-
`For the customer with customer id ${customer.customerId} and user with logToId ${user.logToId} there more then 1 API key`
592-
);
593-
}
594-
if (keys.length == 0) {
595-
return undefined;
596-
}
597-
return keys[0];
589+
return key;
598590
}
599591

600592
async decryptAPIKey(apiKey: string): Promise<string> {

src/utils/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from 'crypto';
2+
13
export function getStripeObjectKey(input: string | { id: string } | null) {
24
if (!input) {
35
return '';
@@ -9,3 +11,7 @@ export function getStripeObjectKey(input: string | { id: string } | null) {
911

1012
return input.id;
1113
}
14+
15+
export function sha256(input: string): string {
16+
return createHash('sha256').update(input).digest('hex');
17+
}

0 commit comments

Comments
 (0)