@tumbaland/backend-core 1.43.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tumbaland/backend-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.45.0",
|
|
4
4
|
"description": "Core shared functionality for Tumbaland backend services",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"testing.js"
|
|
10
|
+
],
|
|
7
11
|
"scripts": {
|
|
8
12
|
"build": "tsc -p tsconfig.build.json",
|
|
9
13
|
"dev": "tsc --watch",
|
package/.versionrc.json
DELETED
package/__mocks__/uuid.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Jest's CJS module loader can't require the real `uuid` package (ESM-only
|
|
2
|
-
// since v9) — Node itself supports require(esm) at runtime, but Jest doesn't.
|
|
3
|
-
// Swap in Node's built-in UUID generator for tests only.
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
|
|
6
|
-
module.exports = {
|
|
7
|
-
v4: () => crypto.randomUUID()
|
|
8
|
-
};
|
package/jest.config.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** @type {import('jest').Config} */
|
|
2
|
-
module.exports = {
|
|
3
|
-
preset: 'ts-jest',
|
|
4
|
-
testEnvironment: 'node',
|
|
5
|
-
testMatch: ['<rootDir>/src/**/*.test.ts'],
|
|
6
|
-
clearMocks: true,
|
|
7
|
-
transform: {
|
|
8
|
-
'^.+\\.ts$': ['ts-jest', { tsconfig: { types: ['jest', 'node'] } }]
|
|
9
|
-
},
|
|
10
|
-
coverageProvider: 'v8',
|
|
11
|
-
// Barrels are excluded, not just the root one: a file of `export { x } from
|
|
12
|
-
// './y'` has no logic to test, but Jest counts every re-exported function as
|
|
13
|
-
// an uncovered one — which is what held global function coverage at ~78%
|
|
14
|
-
// while every module underneath was thoroughly tested.
|
|
15
|
-
collectCoverageFrom: [
|
|
16
|
-
'src/**/*.ts',
|
|
17
|
-
'!src/**/*.test.ts',
|
|
18
|
-
'!src/index.ts',
|
|
19
|
-
'!src/**/index.ts',
|
|
20
|
-
'!src/types/**'
|
|
21
|
-
],
|
|
22
|
-
coverageReporters: ['text', 'json-summary'],
|
|
23
|
-
coverageThreshold: { global: { statements: 90, branches: 80, functions: 90, lines: 90 } }
|
|
24
|
-
};
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { ApiKey, API_KEY_SECRET_FIELDS } from './ApiKey';
|
|
2
|
-
|
|
3
|
-
/** The fields the schema insists on, so each test only varies what it is about. */
|
|
4
|
-
const complete = {
|
|
5
|
-
userId: 'u1',
|
|
6
|
-
userEmail: 'u1@example.com',
|
|
7
|
-
userName: 'Tester',
|
|
8
|
-
name: 'Claude Code',
|
|
9
|
-
keyId: 'abcdef123456',
|
|
10
|
-
hash: 'a'.repeat(64),
|
|
11
|
-
sealedCiphertext: 'ct',
|
|
12
|
-
sealedIv: 'iv',
|
|
13
|
-
sealedTag: 'tag'
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
describe('required fields', () => {
|
|
17
|
-
it('accepts a complete document', () => {
|
|
18
|
-
expect(new ApiKey(complete).validateSync()).toBeUndefined();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it.each([
|
|
22
|
-
'userId',
|
|
23
|
-
'userEmail',
|
|
24
|
-
'name',
|
|
25
|
-
'keyId',
|
|
26
|
-
'hash',
|
|
27
|
-
'sealedCiphertext',
|
|
28
|
-
'sealedIv',
|
|
29
|
-
'sealedTag'
|
|
30
|
-
])('refuses a document with no %s', (field) => {
|
|
31
|
-
const doc = new ApiKey({ ...complete, [field]: undefined });
|
|
32
|
-
expect(doc.validateSync()?.errors[field]).toBeDefined();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('will not store a key without the material needed to verify it', () => {
|
|
36
|
-
// A record missing its digest could never authenticate anything, and one
|
|
37
|
-
// missing the sealed copy could never be revealed — both are corruption,
|
|
38
|
-
// not a valid state.
|
|
39
|
-
const doc = new ApiKey({ ...complete, hash: undefined, sealedCiphertext: undefined });
|
|
40
|
-
const err = doc.validateSync();
|
|
41
|
-
|
|
42
|
-
expect(err?.errors.hash).toBeDefined();
|
|
43
|
-
expect(err?.errors.sealedCiphertext).toBeDefined();
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe('defaults', () => {
|
|
48
|
-
it('treats an unpinned key as personal rather than as unset', () => {
|
|
49
|
-
// `null` and "nobody set this" must not be distinguishable downstream: the
|
|
50
|
-
// middleware reads a null groupId as the owner's personal scope.
|
|
51
|
-
expect(new ApiKey(complete).groupId).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('starts with no scopes, so a key grants nothing until asked', () => {
|
|
55
|
-
expect(new ApiKey(complete).scopes).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('starts the reveal counter at zero', () => {
|
|
59
|
-
expect(new ApiKey(complete).revealCount).toBe(0);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('leaves the lifecycle timestamps unset', () => {
|
|
63
|
-
const doc = new ApiKey(complete);
|
|
64
|
-
|
|
65
|
-
expect(doc.lastUsedAt).toBeUndefined();
|
|
66
|
-
expect(doc.expiresAt).toBeUndefined();
|
|
67
|
-
expect(doc.revokedAt).toBeUndefined();
|
|
68
|
-
expect(doc.lastRevealedAt).toBeUndefined();
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('name', () => {
|
|
73
|
-
it('trims surrounding whitespace', () => {
|
|
74
|
-
expect(new ApiKey({ ...complete, name: ' Claude ' }).name).toBe('Claude');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('caps the length so a listing stays readable', () => {
|
|
78
|
-
const doc = new ApiKey({ ...complete, name: 'x'.repeat(61) });
|
|
79
|
-
expect(doc.validateSync()?.errors.name).toBeDefined();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('accepts a name at the cap', () => {
|
|
83
|
-
const doc = new ApiKey({ ...complete, name: 'x'.repeat(60) });
|
|
84
|
-
expect(doc.validateSync()?.errors.name).toBeUndefined();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe('indexes', () => {
|
|
89
|
-
const indexes = ApiKey.schema.indexes().map(([fields]) => fields);
|
|
90
|
-
|
|
91
|
-
it('indexes keyId, which every verification looks a key up by', () => {
|
|
92
|
-
const path = ApiKey.schema.path('keyId') as unknown as { options: Record<string, unknown> };
|
|
93
|
-
expect(path.options.index).toBe(true);
|
|
94
|
-
expect(path.options.unique).toBe(true);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('indexes a user’s keys newest first, matching how the listing reads them', () => {
|
|
98
|
-
expect(indexes).toContainEqual({ userId: 1, createdAt: -1 });
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('collection', () => {
|
|
103
|
-
it('lives in api_keys', () => {
|
|
104
|
-
// Named explicitly rather than pluralised by Mongoose, so the collection a
|
|
105
|
-
// migration or a manual query targets is not a guess.
|
|
106
|
-
expect(ApiKey.collection.collectionName).toBe('api_keys');
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('model registration', () => {
|
|
111
|
-
it('reuses the compiled model instead of redefining it', () => {
|
|
112
|
-
// backend-core is imported by every service, and some import it more than
|
|
113
|
-
// once through different paths; recompiling would throw OverwriteModelError.
|
|
114
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
115
|
-
const again = require('./ApiKey').ApiKey;
|
|
116
|
-
expect(again).toBe(ApiKey);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('what a key’s audit trail may carry', () => {
|
|
121
|
-
/**
|
|
122
|
-
* The trail is readable by the account owner and kept for a year. A snapshot
|
|
123
|
-
* carrying the hash or the sealed ciphertext would copy credential material
|
|
124
|
-
* into a second collection with a different lifetime — which is exactly what
|
|
125
|
-
* the encryption exists to prevent.
|
|
126
|
-
*/
|
|
127
|
-
it('redacts every field on the schema that holds a secret', () => {
|
|
128
|
-
const secretish = Object.keys(ApiKey.schema.paths).filter((path) =>
|
|
129
|
-
/hash|sealed|secret/i.test(path)
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
// Compared against the schema rather than a hand-written list, so a secret
|
|
133
|
-
// field added later fails here instead of quietly reaching the trail.
|
|
134
|
-
expect(secretish.sort()).toEqual([...API_KEY_SECRET_FIELDS].sort());
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('keeps the fields that make a row worth reading', () => {
|
|
138
|
-
for (const field of ['name', 'scopes', 'tenants', 'revokedAt']) {
|
|
139
|
-
expect(API_KEY_SECRET_FIELDS).not.toContain(field);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
});
|
package/src/apiKeys/ApiKey.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
-
import { auditPlugin } from '../audit/plugin';
|
|
3
|
-
import { PERSONAL_TENANT, type ApiKeyScope, type Tenant } from './types';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* One API key. Lives in backend-core rather than auth-service because both
|
|
7
|
-
* halves need it: auth-service issues and revokes keys, and every other service
|
|
8
|
-
* verifies them on the way in. All services share one database, so the model is
|
|
9
|
-
* shared rather than the lookup going over HTTP on every request.
|
|
10
|
-
*/
|
|
11
|
-
export interface IApiKey extends Document {
|
|
12
|
-
userId: string;
|
|
13
|
-
/**
|
|
14
|
-
* The owner's identity as it stood when the key was issued.
|
|
15
|
-
*
|
|
16
|
-
* Snapshotted so verifying a key stays a single indexed read. Every
|
|
17
|
-
* authenticated request would otherwise need a second query into the users
|
|
18
|
-
* collection to fill in a `UserPayload`, to serve fields almost no handler
|
|
19
|
-
* reads. The cost is that a later rename shows the old name on requests made
|
|
20
|
-
* with this key, which is a fair price and easy to reason about.
|
|
21
|
-
*/
|
|
22
|
-
userEmail: string;
|
|
23
|
-
userName: string;
|
|
24
|
-
/** what the user called it — "Claude Code", "home assistant" */
|
|
25
|
-
name: string;
|
|
26
|
-
/** the public half of the token; unique, and what a presented key is looked up by */
|
|
27
|
-
keyId: string;
|
|
28
|
-
/** SHA-256 of the secret half — what verification actually compares against */
|
|
29
|
-
hash: string;
|
|
30
|
-
/** the whole token, encrypted, so the owner can read it back later */
|
|
31
|
-
sealedCiphertext: string;
|
|
32
|
-
sealedIv: string;
|
|
33
|
-
sealedTag: string;
|
|
34
|
-
scopes: ApiKeyScope[];
|
|
35
|
-
/** every tenant this key may act in; see `Tenant` */
|
|
36
|
-
tenants: Tenant[];
|
|
37
|
-
/** the one it acts in when a request names none; always a member of `tenants` */
|
|
38
|
-
defaultTenant: Tenant;
|
|
39
|
-
/**
|
|
40
|
-
* The single tenant this key was pinned to, before tenants were a set.
|
|
41
|
-
*
|
|
42
|
-
* Kept so keys issued under the old model keep working; `readTenants` falls
|
|
43
|
-
* back to it. Nothing writes it any more.
|
|
44
|
-
*/
|
|
45
|
-
groupId?: string | null;
|
|
46
|
-
lastUsedAt?: Date;
|
|
47
|
-
expiresAt?: Date;
|
|
48
|
-
revokedAt?: Date;
|
|
49
|
-
/** reveals are counted and timestamped — reading back a key is worth an audit trail */
|
|
50
|
-
revealCount: number;
|
|
51
|
-
lastRevealedAt?: Date;
|
|
52
|
-
createdAt: Date;
|
|
53
|
-
updatedAt: Date;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const ApiKeySchema = new Schema<IApiKey>(
|
|
57
|
-
{
|
|
58
|
-
userId: { type: String, required: true, index: true },
|
|
59
|
-
userEmail: { type: String, required: true },
|
|
60
|
-
userName: { type: String, required: true, default: '' },
|
|
61
|
-
name: { type: String, required: true, trim: true, maxlength: 60 },
|
|
62
|
-
keyId: { type: String, required: true, unique: true, index: true },
|
|
63
|
-
hash: { type: String, required: true },
|
|
64
|
-
sealedCiphertext: { type: String, required: true },
|
|
65
|
-
sealedIv: { type: String, required: true },
|
|
66
|
-
sealedTag: { type: String, required: true },
|
|
67
|
-
scopes: { type: [String], default: [] },
|
|
68
|
-
// Defaulted rather than optional: which data a key reaches is a decision the
|
|
69
|
-
// creator made, and it must not be indistinguishable from a field nobody set.
|
|
70
|
-
tenants: { type: [String], default: () => [PERSONAL_TENANT] },
|
|
71
|
-
defaultTenant: { type: String, default: PERSONAL_TENANT },
|
|
72
|
-
// Written by the single-tenant model this replaced; read-only now.
|
|
73
|
-
groupId: { type: String, default: null },
|
|
74
|
-
lastUsedAt: { type: Date },
|
|
75
|
-
expiresAt: { type: Date },
|
|
76
|
-
revokedAt: { type: Date },
|
|
77
|
-
revealCount: { type: Number, default: 0 },
|
|
78
|
-
lastRevealedAt: { type: Date }
|
|
79
|
-
},
|
|
80
|
-
{ timestamps: true, collection: 'api_keys' }
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
// Every listing is one user's keys, newest first.
|
|
84
|
-
ApiKeySchema.index({ userId: 1, createdAt: -1 });
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Issuing and revoking a key is worth a record — arguably more than anything a
|
|
88
|
-
* key goes on to do, since a credential nobody remembers creating is the one
|
|
89
|
-
* that matters.
|
|
90
|
-
*
|
|
91
|
-
* Every secret-bearing field is redacted. The trail is readable by the account
|
|
92
|
-
* owner and kept for a year; a snapshot carrying `hash` or the sealed
|
|
93
|
-
* ciphertext would put credential material into a second collection with a
|
|
94
|
-
* different lifetime, which is precisely the thing the encryption is for.
|
|
95
|
-
*/
|
|
96
|
-
export const API_KEY_SECRET_FIELDS = ['hash', 'sealedCiphertext', 'sealedIv', 'sealedTag'];
|
|
97
|
-
|
|
98
|
-
ApiKeySchema.plugin(auditPlugin, { resource: 'api_key', redact: API_KEY_SECRET_FIELDS });
|
|
99
|
-
|
|
100
|
-
export const ApiKey = mongoose.models.ApiKey
|
|
101
|
-
? (mongoose.models.ApiKey as mongoose.Model<IApiKey>)
|
|
102
|
-
: mongoose.model<IApiKey>('ApiKey', ApiKeySchema);
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
const ORIGINAL_ENV = process.env;
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
generateKey,
|
|
5
|
-
parseKey,
|
|
6
|
-
looksLikeApiKey,
|
|
7
|
-
secretMatches,
|
|
8
|
-
encryptSecret,
|
|
9
|
-
decryptSecret,
|
|
10
|
-
displayPrefix,
|
|
11
|
-
sha256,
|
|
12
|
-
resetEncryptionKeyCache
|
|
13
|
-
} from './crypto';
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
|
|
17
|
-
resetEncryptionKeyCache();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
process.env = ORIGINAL_ENV;
|
|
22
|
-
resetEncryptionKeyCache();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe('generateKey', () => {
|
|
26
|
-
it('produces a prefixed token whose parts round-trip through parseKey', () => {
|
|
27
|
-
const { token, id, hash } = generateKey();
|
|
28
|
-
const parsed = parseKey(token);
|
|
29
|
-
|
|
30
|
-
expect(token.startsWith('tmb_live_')).toBe(true);
|
|
31
|
-
expect(parsed).not.toBeNull();
|
|
32
|
-
expect(parsed!.id).toBe(id);
|
|
33
|
-
expect(sha256(parsed!.secret)).toBe(hash);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('never repeats a key', () => {
|
|
37
|
-
const tokens = new Set(Array.from({ length: 50 }, () => generateKey().token));
|
|
38
|
-
expect(tokens.size).toBe(50);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('does not store the secret in recoverable form in the hash', () => {
|
|
42
|
-
const { token, hash } = generateKey();
|
|
43
|
-
expect(hash).not.toContain(token);
|
|
44
|
-
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('parseKey', () => {
|
|
49
|
-
it.each([
|
|
50
|
-
['a non-string', 12345],
|
|
51
|
-
['an empty string', ''],
|
|
52
|
-
['a foreign prefix', 'sk_live_abcdef123456_secretsecretsecret'],
|
|
53
|
-
['too few parts', 'tmb_live_abcdef123456'],
|
|
54
|
-
['a missing secret', 'tmb_live_abcdef123456_'],
|
|
55
|
-
['a non-hex id', 'tmb_live_ZZZZZZZZZZZZ_secretsecretsecret'],
|
|
56
|
-
['a short id', 'tmb_live_abc_secretsecretsecret'],
|
|
57
|
-
['a truncated secret', 'tmb_live_abcdef123456_short']
|
|
58
|
-
])('rejects %s', (_label, token) => {
|
|
59
|
-
expect(parseKey(token)).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('accepts a secret containing underscores, which base64url produces', () => {
|
|
63
|
-
// The alphabet is A-Za-z0-9-_, so roughly a third of real keys carry an
|
|
64
|
-
// underscore in the secret. Splitting the token on '_' rejected those.
|
|
65
|
-
expect(parseKey('tmb_live_abcdef123456_aa_bb_cc-dd_eeffgghhiijj')).toEqual({
|
|
66
|
-
id: 'abcdef123456',
|
|
67
|
-
secret: 'aa_bb_cc-dd_eeffgghhiijj'
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('round-trips every generated key, underscores and all', () => {
|
|
72
|
-
for (let i = 0; i < 200; i++) {
|
|
73
|
-
const { token, id } = generateKey();
|
|
74
|
-
expect(parseKey(token)?.id).toBe(id);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('accepts a well-formed key', () => {
|
|
79
|
-
expect(parseKey('tmb_live_abcdef123456_aaaaaaaaaaaaaaaaaaaa')).toEqual({
|
|
80
|
-
id: 'abcdef123456',
|
|
81
|
-
secret: 'aaaaaaaaaaaaaaaaaaaa'
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('looksLikeApiKey', () => {
|
|
87
|
-
it('separates our keys from JWTs so the caller can skip a pointless verify', () => {
|
|
88
|
-
expect(looksLikeApiKey(generateKey().token)).toBe(true);
|
|
89
|
-
expect(looksLikeApiKey('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc.def')).toBe(false);
|
|
90
|
-
expect(looksLikeApiKey(undefined)).toBe(false);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('secretMatches', () => {
|
|
95
|
-
it('accepts the right secret and rejects a wrong one', () => {
|
|
96
|
-
const { token, hash } = generateKey();
|
|
97
|
-
const { secret } = parseKey(token)!;
|
|
98
|
-
|
|
99
|
-
expect(secretMatches(secret, hash)).toBe(true);
|
|
100
|
-
expect(secretMatches(`${secret}x`, hash)).toBe(false);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('returns false rather than throwing on a malformed stored hash', () => {
|
|
104
|
-
// timingSafeEqual throws on a length mismatch, which would turn a corrupt
|
|
105
|
-
// record into a 500 instead of a failed authentication.
|
|
106
|
-
expect(secretMatches('anything', 'not-a-sha256')).toBe(false);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('encryptSecret / decryptSecret', () => {
|
|
111
|
-
it('round-trips a token', () => {
|
|
112
|
-
const { token } = generateKey();
|
|
113
|
-
expect(decryptSecret(encryptSecret(token))).toBe(token);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('produces different ciphertext each time, so equal keys are not detectable', () => {
|
|
117
|
-
const { token } = generateKey();
|
|
118
|
-
const a = encryptSecret(token);
|
|
119
|
-
const b = encryptSecret(token);
|
|
120
|
-
|
|
121
|
-
expect(a.ciphertext).not.toBe(b.ciphertext);
|
|
122
|
-
expect(a.iv).not.toBe(b.iv);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('refuses to decrypt tampered ciphertext rather than returning rubbish', () => {
|
|
126
|
-
const sealed = encryptSecret(generateKey().token);
|
|
127
|
-
const flipped = Buffer.from(sealed.ciphertext, 'base64');
|
|
128
|
-
flipped[0] ^= 0xff;
|
|
129
|
-
|
|
130
|
-
expect(() =>
|
|
131
|
-
decryptSecret({ ...sealed, ciphertext: flipped.toString('base64') })
|
|
132
|
-
).toThrow();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('cannot be decrypted with a different encryption secret', () => {
|
|
136
|
-
const sealed = encryptSecret(generateKey().token);
|
|
137
|
-
|
|
138
|
-
process.env.API_KEY_ENCRYPTION_SECRET = 'a-completely-different-secret';
|
|
139
|
-
resetEncryptionKeyCache();
|
|
140
|
-
|
|
141
|
-
expect(() => decryptSecret(sealed)).toThrow();
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('requires the encryption secret to be configured at all', () => {
|
|
145
|
-
delete process.env.API_KEY_ENCRYPTION_SECRET;
|
|
146
|
-
resetEncryptionKeyCache();
|
|
147
|
-
|
|
148
|
-
expect(() => encryptSecret('tmb_live_x')).toThrow(/API_KEY_ENCRYPTION_SECRET/);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('displayPrefix', () => {
|
|
153
|
-
it('identifies a key without exposing enough to use it', () => {
|
|
154
|
-
const { token, id } = generateKey();
|
|
155
|
-
const prefix = displayPrefix(id);
|
|
156
|
-
|
|
157
|
-
expect(prefix).toBe(`tmb_live_${id}`);
|
|
158
|
-
expect(token.startsWith(prefix)).toBe(true);
|
|
159
|
-
expect(prefix.length).toBeLessThan(token.length);
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/apiKeys/crypto.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createCipheriv,
|
|
3
|
-
createDecipheriv,
|
|
4
|
-
createHash,
|
|
5
|
-
randomBytes,
|
|
6
|
-
scryptSync,
|
|
7
|
-
timingSafeEqual
|
|
8
|
-
} from 'crypto';
|
|
9
|
-
import { requireEnv } from '../config/env';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Key material and the two different things we do with it.
|
|
13
|
-
*
|
|
14
|
-
* A presented key is checked against a SHA-256 digest — fast, because it runs on
|
|
15
|
-
* every authenticated request. Plain SHA-256 rather than bcrypt is right here
|
|
16
|
-
* and wrong for passwords: the secret is 32 bytes from a CSPRNG, so there is no
|
|
17
|
-
* dictionary to attack and nothing for a slow hash to buy.
|
|
18
|
-
*
|
|
19
|
-
* Separately, the key is stored encrypted so the owner can look it up again
|
|
20
|
-
* later. That is a deliberate trade — see `encryptSecret` — and the reason the
|
|
21
|
-
* digest is kept as well: verification never touches the reversible copy.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** `tmb_live_<id>_<secret>` — the prefix makes a leaked key greppable in logs. */
|
|
25
|
-
const KEY_PREFIX = 'tmb_live';
|
|
26
|
-
const ID_BYTES = 6; // 12 hex chars, the public half
|
|
27
|
-
const SECRET_BYTES = 32;
|
|
28
|
-
|
|
29
|
-
const ALGORITHM = 'aes-256-gcm';
|
|
30
|
-
const IV_BYTES = 12;
|
|
31
|
-
/** Fixed: the input is a high-entropy env secret, not a password, so a per-record salt buys nothing. */
|
|
32
|
-
const KDF_SALT = 'tumbaland/api-key/v1';
|
|
33
|
-
|
|
34
|
-
export interface GeneratedKey {
|
|
35
|
-
/** the whole key, shown to the user and never stored in this form */
|
|
36
|
-
token: string;
|
|
37
|
-
/** the public half, indexed for lookup */
|
|
38
|
-
id: string;
|
|
39
|
-
/** SHA-256 of the secret half, for verification */
|
|
40
|
-
hash: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Derive the AES key once per process; scrypt is deliberately slow. */
|
|
44
|
-
let cachedKey: Buffer | null = null;
|
|
45
|
-
function encryptionKey(): Buffer {
|
|
46
|
-
if (!cachedKey) {
|
|
47
|
-
cachedKey = scryptSync(requireEnv('API_KEY_ENCRYPTION_SECRET'), KDF_SALT, 32);
|
|
48
|
-
}
|
|
49
|
-
return cachedKey;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Test seam: the derived key is cached for the life of the process. */
|
|
53
|
-
export const resetEncryptionKeyCache = (): void => {
|
|
54
|
-
cachedKey = null;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Whether keys can be issued and revealed at all.
|
|
59
|
-
*
|
|
60
|
-
* Only the reversible copy needs the secret — verification runs off the digest —
|
|
61
|
-
* so a service that merely accepts keys works without it. That asymmetry is easy
|
|
62
|
-
* to get wrong in a deployment, so callers can ask rather than discovering it
|
|
63
|
-
* through a 500 on someone's first key.
|
|
64
|
-
*/
|
|
65
|
-
export const isEncryptionConfigured = (): boolean =>
|
|
66
|
-
Boolean(process.env.API_KEY_ENCRYPTION_SECRET);
|
|
67
|
-
|
|
68
|
-
export const sha256 = (value: string): string =>
|
|
69
|
-
createHash('sha256').update(value, 'utf8').digest('hex');
|
|
70
|
-
|
|
71
|
-
export const generateKey = (): GeneratedKey => {
|
|
72
|
-
const id = randomBytes(ID_BYTES).toString('hex');
|
|
73
|
-
const secret = randomBytes(SECRET_BYTES).toString('base64url');
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
token: `${KEY_PREFIX}_${id}_${secret}`,
|
|
77
|
-
id,
|
|
78
|
-
hash: sha256(secret)
|
|
79
|
-
};
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export interface ParsedKey {
|
|
83
|
-
id: string;
|
|
84
|
-
secret: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Split a presented key into its public id and its secret half.
|
|
89
|
-
*
|
|
90
|
-
* Returns null rather than throwing: an unparseable token is an ordinary failed
|
|
91
|
-
* authentication, not an exceptional condition, and the caller answers both the
|
|
92
|
-
* same way.
|
|
93
|
-
*/
|
|
94
|
-
export const parseKey = (token: unknown): ParsedKey | null => {
|
|
95
|
-
if (typeof token !== 'string') return null;
|
|
96
|
-
|
|
97
|
-
// Matched positionally rather than split on '_': the base64url alphabet
|
|
98
|
-
// includes '_', so a secret can contain any number of them and splitting
|
|
99
|
-
// would reject roughly a third of otherwise valid keys. The id is
|
|
100
|
-
// fixed-width, so everything after it is the secret.
|
|
101
|
-
const match = token.match(/^tmb_live_([0-9a-f]{12})_(.+)$/);
|
|
102
|
-
if (!match) return null;
|
|
103
|
-
if (match[2].length < 16) return null;
|
|
104
|
-
|
|
105
|
-
return { id: match[1], secret: match[2] };
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
/** True when a token even looks like one of ours — lets the caller skip a JWT parse. */
|
|
109
|
-
export const looksLikeApiKey = (token: unknown): boolean =>
|
|
110
|
-
typeof token === 'string' && token.startsWith(`${KEY_PREFIX}_`);
|
|
111
|
-
|
|
112
|
-
/** Constant-time digest comparison, so a wrong key leaks nothing through timing. */
|
|
113
|
-
export const secretMatches = (secret: string, expectedHash: string): boolean => {
|
|
114
|
-
const presented = Buffer.from(sha256(secret), 'hex');
|
|
115
|
-
const expected = Buffer.from(expectedHash, 'hex');
|
|
116
|
-
if (presented.length !== expected.length) return false;
|
|
117
|
-
return timingSafeEqual(presented, expected);
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
export interface SealedSecret {
|
|
121
|
-
ciphertext: string;
|
|
122
|
-
iv: string;
|
|
123
|
-
tag: string;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Encrypt the key so it can be shown again later.
|
|
128
|
-
*
|
|
129
|
-
* Storing a recoverable copy is weaker than hashing alone: whoever holds both
|
|
130
|
-
* the database and `API_KEY_ENCRYPTION_SECRET` can mint the plaintext. It buys
|
|
131
|
-
* the ability to re-read a key you have lost, which the product wants. The
|
|
132
|
-
* secret lives outside the database precisely so that a dump, a backup, or a
|
|
133
|
-
* read-only replica is not on its own enough.
|
|
134
|
-
*/
|
|
135
|
-
export const encryptSecret = (token: string): SealedSecret => {
|
|
136
|
-
const iv = randomBytes(IV_BYTES);
|
|
137
|
-
const cipher = createCipheriv(ALGORITHM, encryptionKey(), iv);
|
|
138
|
-
const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
ciphertext: ciphertext.toString('base64'),
|
|
142
|
-
iv: iv.toString('base64'),
|
|
143
|
-
tag: cipher.getAuthTag().toString('base64')
|
|
144
|
-
};
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Recover a stored key. Throws when the ciphertext has been tampered with —
|
|
149
|
-
* GCM authenticates, so a modified record fails loudly instead of returning
|
|
150
|
-
* plausible rubbish.
|
|
151
|
-
*/
|
|
152
|
-
export const decryptSecret = (sealed: SealedSecret): string => {
|
|
153
|
-
const decipher = createDecipheriv(ALGORITHM, encryptionKey(), Buffer.from(sealed.iv, 'base64'));
|
|
154
|
-
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
|
155
|
-
|
|
156
|
-
return Buffer.concat([
|
|
157
|
-
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
|
|
158
|
-
decipher.final()
|
|
159
|
-
]).toString('utf8');
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
/** What the UI shows in a list: enough to tell two keys apart, not enough to use one. */
|
|
163
|
-
export const displayPrefix = (id: string): string => `${KEY_PREFIX}_${id}`;
|