@de-otio/chaoskb-server 0.2.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/dist/lib/admin-handler/index.d.ts +22 -0
- package/dist/lib/admin-handler/index.d.ts.map +1 -0
- package/dist/lib/admin-handler/index.js +92 -0
- package/dist/lib/admin-handler/index.js.map +1 -0
- package/dist/lib/admin-handler/index.ts +123 -0
- package/dist/lib/admin-handler/routes/metrics.d.ts +11 -0
- package/dist/lib/admin-handler/routes/metrics.d.ts.map +1 -0
- package/dist/lib/admin-handler/routes/metrics.js +200 -0
- package/dist/lib/admin-handler/routes/metrics.js.map +1 -0
- package/dist/lib/admin-handler/routes/metrics.ts +234 -0
- package/dist/lib/admin-handler/routes/overview.d.ts +9 -0
- package/dist/lib/admin-handler/routes/overview.d.ts.map +1 -0
- package/dist/lib/admin-handler/routes/overview.js +110 -0
- package/dist/lib/admin-handler/routes/overview.js.map +1 -0
- package/dist/lib/admin-handler/routes/overview.ts +133 -0
- package/dist/lib/admin-handler/routes/tenants.d.ts +10 -0
- package/dist/lib/admin-handler/routes/tenants.d.ts.map +1 -0
- package/dist/lib/admin-handler/routes/tenants.js +108 -0
- package/dist/lib/admin-handler/routes/tenants.js.map +1 -0
- package/dist/lib/admin-handler/routes/tenants.ts +134 -0
- package/dist/lib/chaoskb-stack.d.ts +22 -0
- package/dist/lib/chaoskb-stack.d.ts.map +1 -0
- package/dist/lib/chaoskb-stack.js +60 -0
- package/dist/lib/chaoskb-stack.js.map +1 -0
- package/dist/lib/constructs/admin-api.d.ts +16 -0
- package/dist/lib/constructs/admin-api.d.ts.map +1 -0
- package/dist/lib/constructs/admin-api.js +93 -0
- package/dist/lib/constructs/admin-api.js.map +1 -0
- package/dist/lib/constructs/admin-dashboard.d.ts +18 -0
- package/dist/lib/constructs/admin-dashboard.d.ts.map +1 -0
- package/dist/lib/constructs/admin-dashboard.js +172 -0
- package/dist/lib/constructs/admin-dashboard.js.map +1 -0
- package/dist/lib/constructs/api.d.ts +17 -0
- package/dist/lib/constructs/api.d.ts.map +1 -0
- package/dist/lib/constructs/api.js +81 -0
- package/dist/lib/constructs/api.js.map +1 -0
- package/dist/lib/constructs/auth.d.ts +11 -0
- package/dist/lib/constructs/auth.d.ts.map +1 -0
- package/dist/lib/constructs/auth.js +18 -0
- package/dist/lib/constructs/auth.js.map +1 -0
- package/dist/lib/constructs/blob-store.d.ts +10 -0
- package/dist/lib/constructs/blob-store.d.ts.map +1 -0
- package/dist/lib/constructs/blob-store.js +31 -0
- package/dist/lib/constructs/blob-store.js.map +1 -0
- package/dist/lib/deploy-cli.d.ts +3 -0
- package/dist/lib/deploy-cli.d.ts.map +1 -0
- package/dist/lib/deploy-cli.js +49 -0
- package/dist/lib/deploy-cli.js.map +1 -0
- package/dist/lib/handler/index.d.ts +23 -0
- package/dist/lib/handler/index.d.ts.map +1 -0
- package/dist/lib/handler/index.js +276 -0
- package/dist/lib/handler/index.js.map +1 -0
- package/dist/lib/handler/index.ts +372 -0
- package/dist/lib/handler/logger.d.ts +16 -0
- package/dist/lib/handler/logger.d.ts.map +1 -0
- package/dist/lib/handler/logger.js +26 -0
- package/dist/lib/handler/logger.js.map +1 -0
- package/dist/lib/handler/logger.ts +36 -0
- package/dist/lib/handler/middleware/input-validation.d.ts +6 -0
- package/dist/lib/handler/middleware/input-validation.d.ts.map +1 -0
- package/dist/lib/handler/middleware/input-validation.js +36 -0
- package/dist/lib/handler/middleware/input-validation.js.map +1 -0
- package/dist/lib/handler/middleware/input-validation.ts +44 -0
- package/dist/lib/handler/middleware/rate-limit.d.ts +14 -0
- package/dist/lib/handler/middleware/rate-limit.d.ts.map +1 -0
- package/dist/lib/handler/middleware/rate-limit.js +94 -0
- package/dist/lib/handler/middleware/rate-limit.js.map +1 -0
- package/dist/lib/handler/middleware/rate-limit.ts +121 -0
- package/dist/lib/handler/middleware/ssh-auth.d.ts +48 -0
- package/dist/lib/handler/middleware/ssh-auth.d.ts.map +1 -0
- package/dist/lib/handler/middleware/ssh-auth.js +256 -0
- package/dist/lib/handler/middleware/ssh-auth.js.map +1 -0
- package/dist/lib/handler/middleware/ssh-auth.ts +300 -0
- package/dist/lib/handler/routes/audit.d.ts +24 -0
- package/dist/lib/handler/routes/audit.d.ts.map +1 -0
- package/dist/lib/handler/routes/audit.js +94 -0
- package/dist/lib/handler/routes/audit.js.map +1 -0
- package/dist/lib/handler/routes/audit.ts +101 -0
- package/dist/lib/handler/routes/blobs.d.ts +13 -0
- package/dist/lib/handler/routes/blobs.d.ts.map +1 -0
- package/dist/lib/handler/routes/blobs.js +298 -0
- package/dist/lib/handler/routes/blobs.js.map +1 -0
- package/dist/lib/handler/routes/blobs.ts +348 -0
- package/dist/lib/handler/routes/devices.d.ts +48 -0
- package/dist/lib/handler/routes/devices.d.ts.map +1 -0
- package/dist/lib/handler/routes/devices.js +394 -0
- package/dist/lib/handler/routes/devices.js.map +1 -0
- package/dist/lib/handler/routes/devices.ts +458 -0
- package/dist/lib/handler/routes/export.d.ts +9 -0
- package/dist/lib/handler/routes/export.d.ts.map +1 -0
- package/dist/lib/handler/routes/export.js +40 -0
- package/dist/lib/handler/routes/export.js.map +1 -0
- package/dist/lib/handler/routes/export.ts +55 -0
- package/dist/lib/handler/routes/github.d.ts +31 -0
- package/dist/lib/handler/routes/github.d.ts.map +1 -0
- package/dist/lib/handler/routes/github.js +118 -0
- package/dist/lib/handler/routes/github.js.map +1 -0
- package/dist/lib/handler/routes/github.ts +162 -0
- package/dist/lib/handler/routes/health.d.ts +6 -0
- package/dist/lib/handler/routes/health.d.ts.map +1 -0
- package/dist/lib/handler/routes/health.js +14 -0
- package/dist/lib/handler/routes/health.js.map +1 -0
- package/dist/lib/handler/routes/health.ts +10 -0
- package/dist/lib/handler/routes/invites.d.ts +24 -0
- package/dist/lib/handler/routes/invites.d.ts.map +1 -0
- package/dist/lib/handler/routes/invites.js +445 -0
- package/dist/lib/handler/routes/invites.js.map +1 -0
- package/dist/lib/handler/routes/invites.ts +527 -0
- package/dist/lib/handler/routes/notifications.d.ts +39 -0
- package/dist/lib/handler/routes/notifications.d.ts.map +1 -0
- package/dist/lib/handler/routes/notifications.js +150 -0
- package/dist/lib/handler/routes/notifications.js.map +1 -0
- package/dist/lib/handler/routes/notifications.ts +163 -0
- package/dist/lib/handler/routes/projects.d.ts +24 -0
- package/dist/lib/handler/routes/projects.d.ts.map +1 -0
- package/dist/lib/handler/routes/projects.js +47 -0
- package/dist/lib/handler/routes/projects.js.map +1 -0
- package/dist/lib/handler/routes/projects.ts +69 -0
- package/dist/lib/handler/routes/register.d.ts +19 -0
- package/dist/lib/handler/routes/register.d.ts.map +1 -0
- package/dist/lib/handler/routes/register.js +327 -0
- package/dist/lib/handler/routes/register.js.map +1 -0
- package/dist/lib/handler/routes/register.ts +363 -0
- package/dist/lib/handler/routes/restore.d.ts +9 -0
- package/dist/lib/handler/routes/restore.d.ts.map +1 -0
- package/dist/lib/handler/routes/restore.js +52 -0
- package/dist/lib/handler/routes/restore.js.map +1 -0
- package/dist/lib/handler/routes/restore.ts +73 -0
- package/dist/lib/handler/routes/revocation.d.ts +13 -0
- package/dist/lib/handler/routes/revocation.d.ts.map +1 -0
- package/dist/lib/handler/routes/revocation.js +63 -0
- package/dist/lib/handler/routes/revocation.js.map +1 -0
- package/dist/lib/handler/routes/revocation.ts +87 -0
- package/dist/lib/handler/routes/rotation.d.ts +24 -0
- package/dist/lib/handler/routes/rotation.d.ts.map +1 -0
- package/dist/lib/handler/routes/rotation.js +291 -0
- package/dist/lib/handler/routes/rotation.js.map +1 -0
- package/dist/lib/handler/routes/rotation.ts +336 -0
- package/dist/lib/handler/routes/tenants.d.ts +11 -0
- package/dist/lib/handler/routes/tenants.d.ts.map +1 -0
- package/dist/lib/handler/routes/tenants.js +181 -0
- package/dist/lib/handler/routes/tenants.js.map +1 -0
- package/dist/lib/handler/routes/tenants.ts +198 -0
- package/dist/lib/handler/routes/wrapped-key.d.ts +21 -0
- package/dist/lib/handler/routes/wrapped-key.d.ts.map +1 -0
- package/dist/lib/handler/routes/wrapped-key.js +76 -0
- package/dist/lib/handler/routes/wrapped-key.js.map +1 -0
- package/dist/lib/handler/routes/wrapped-key.ts +108 -0
- package/dist/lib/index.d.ts +7 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +16 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/vitest.config.d.ts +3 -0
- package/dist/vitest.config.d.ts.map +1 -0
- package/dist/vitest.config.js +18 -0
- package/dist/vitest.config.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
|
|
3
|
+
import { logAuditEvent } from './audit.js';
|
|
4
|
+
import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
|
|
5
|
+
import { logger } from '../logger.js';
|
|
6
|
+
import {
|
|
7
|
+
verifyKeyOnGitHub,
|
|
8
|
+
storeGitHubAssociation,
|
|
9
|
+
storeGitHubReverseLookup,
|
|
10
|
+
findTenantByGitHub,
|
|
11
|
+
GitHubVerificationError,
|
|
12
|
+
} from './github.js';
|
|
13
|
+
|
|
14
|
+
interface RegisterRequest {
|
|
15
|
+
publicKey: string;
|
|
16
|
+
signedChallenge: string;
|
|
17
|
+
challengeNonce: string;
|
|
18
|
+
github?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface HandlerResponse {
|
|
22
|
+
statusCode: number;
|
|
23
|
+
body: string;
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CHALLENGE_EXPIRY_SECONDS = 60;
|
|
28
|
+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
29
|
+
|
|
30
|
+
let cachedSignupsEnabled: { value: boolean; expiresAt: number } | null = null;
|
|
31
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
32
|
+
|
|
33
|
+
const ssmClient = new SSMClient({});
|
|
34
|
+
|
|
35
|
+
export async function checkSignupsEnabled(paramName: string): Promise<boolean> {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (cachedSignupsEnabled && now < cachedSignupsEnabled.expiresAt) {
|
|
38
|
+
return cachedSignupsEnabled.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await ssmClient.send(
|
|
43
|
+
new GetParameterCommand({ Name: paramName }),
|
|
44
|
+
);
|
|
45
|
+
const value = result.Parameter?.Value !== 'false';
|
|
46
|
+
cachedSignupsEnabled = { value, expiresAt: now + CACHE_TTL_MS };
|
|
47
|
+
return value;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
logger.error('Failed to fetch signups-enabled parameter', { error: String(err) });
|
|
50
|
+
// Default to enabled if parameter fetch fails
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Exported for testing
|
|
56
|
+
export function _resetSignupsCache(): void {
|
|
57
|
+
cachedSignupsEnabled = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tenantIdFromPublicKey(publicKeyBase64: string): string {
|
|
61
|
+
const hash = crypto.createHash('sha256').update(publicKeyBase64).digest('hex');
|
|
62
|
+
return hash.slice(0, 32);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isValidSSHPublicKey(publicKey: string): boolean {
|
|
66
|
+
// Basic validation: must be base64 and reasonable length
|
|
67
|
+
if (!publicKey || publicKey.length < 16 || publicKey.length > 2048) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const decoded = Buffer.from(publicKey, 'base64');
|
|
72
|
+
return decoded.length > 0 && publicKey === decoded.toString('base64');
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verify an Ed25519 signature of a challenge nonce against a public key.
|
|
80
|
+
* The signed data is: "chaoskb-register\n" + nonce (base64).
|
|
81
|
+
*/
|
|
82
|
+
function verifyRegistrationSignature(
|
|
83
|
+
publicKeyBase64: string,
|
|
84
|
+
nonce: string,
|
|
85
|
+
signatureBase64: string,
|
|
86
|
+
): boolean {
|
|
87
|
+
try {
|
|
88
|
+
const publicKeyBuffer = Buffer.from(publicKeyBase64, 'base64');
|
|
89
|
+
const signatureBuffer = Buffer.from(signatureBase64, 'base64');
|
|
90
|
+
const data = Buffer.from(`chaoskb-register\n${nonce}`);
|
|
91
|
+
|
|
92
|
+
const keyObject = crypto.createPublicKey({
|
|
93
|
+
key: Buffer.concat([
|
|
94
|
+
// Ed25519 DER prefix for a 32-byte public key
|
|
95
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
96
|
+
publicKeyBuffer,
|
|
97
|
+
]),
|
|
98
|
+
format: 'der',
|
|
99
|
+
type: 'spki',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return crypto.verify(null, data, keyObject, signatureBuffer);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* GET /v1/register/challenge — generate a registration challenge nonce.
|
|
110
|
+
*
|
|
111
|
+
* Returns a 32-byte random nonce (base64-encoded) that must be signed by the
|
|
112
|
+
* client's SSH private key and submitted with the registration request.
|
|
113
|
+
* Challenge expires after 60 seconds and is single-use.
|
|
114
|
+
*/
|
|
115
|
+
export async function handleChallenge(
|
|
116
|
+
ddb: DynamoDBDocumentClient,
|
|
117
|
+
tableName: string,
|
|
118
|
+
): Promise<HandlerResponse> {
|
|
119
|
+
const nonce = crypto.randomBytes(32).toString('base64');
|
|
120
|
+
const now = Math.floor(Date.now() / 1000);
|
|
121
|
+
const ttl = now + CHALLENGE_EXPIRY_SECONDS + 60; // DynamoDB TTL: generous buffer
|
|
122
|
+
const expiresAt = new Date((now + CHALLENGE_EXPIRY_SECONDS) * 1000).toISOString();
|
|
123
|
+
|
|
124
|
+
await ddb.send(
|
|
125
|
+
new PutCommand({
|
|
126
|
+
TableName: tableName,
|
|
127
|
+
Item: {
|
|
128
|
+
PK: `CHALLENGE#${nonce}`,
|
|
129
|
+
SK: 'META',
|
|
130
|
+
expiresAt,
|
|
131
|
+
ttl,
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
logger.info('Registration challenge created');
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
statusCode: 200,
|
|
140
|
+
headers: JSON_HEADERS,
|
|
141
|
+
body: JSON.stringify({ challenge: nonce, expiresAt }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function handleRegister(
|
|
146
|
+
body: string | null | undefined,
|
|
147
|
+
ddb: DynamoDBDocumentClient,
|
|
148
|
+
tableName: string,
|
|
149
|
+
signupsParamName: string,
|
|
150
|
+
): Promise<HandlerResponse> {
|
|
151
|
+
// Check if signups are enabled
|
|
152
|
+
const signupsEnabled = await checkSignupsEnabled(signupsParamName);
|
|
153
|
+
if (!signupsEnabled) {
|
|
154
|
+
return {
|
|
155
|
+
statusCode: 403,
|
|
156
|
+
headers: JSON_HEADERS,
|
|
157
|
+
body: JSON.stringify({ error: 'signups_disabled', message: 'New registrations are currently disabled' }),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Parse and validate request body
|
|
162
|
+
if (!body) {
|
|
163
|
+
return {
|
|
164
|
+
statusCode: 400,
|
|
165
|
+
headers: JSON_HEADERS,
|
|
166
|
+
body: JSON.stringify({ error: 'invalid_request', message: 'Request body is required' }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let request: RegisterRequest;
|
|
171
|
+
try {
|
|
172
|
+
request = JSON.parse(body);
|
|
173
|
+
} catch {
|
|
174
|
+
return {
|
|
175
|
+
statusCode: 400,
|
|
176
|
+
headers: JSON_HEADERS,
|
|
177
|
+
body: JSON.stringify({ error: 'invalid_request', message: 'Invalid JSON body' }),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!request.publicKey) {
|
|
182
|
+
return {
|
|
183
|
+
statusCode: 400,
|
|
184
|
+
headers: JSON_HEADERS,
|
|
185
|
+
body: JSON.stringify({ error: 'invalid_request', message: 'publicKey is required' }),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!request.signedChallenge || !request.challengeNonce) {
|
|
190
|
+
return {
|
|
191
|
+
statusCode: 400,
|
|
192
|
+
headers: JSON_HEADERS,
|
|
193
|
+
body: JSON.stringify({ error: 'invalid_request', message: 'signedChallenge and challengeNonce are required' }),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!isValidSSHPublicKey(request.publicKey)) {
|
|
198
|
+
return {
|
|
199
|
+
statusCode: 400,
|
|
200
|
+
headers: JSON_HEADERS,
|
|
201
|
+
body: JSON.stringify({ error: 'invalid_request', message: 'Invalid SSH public key format' }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Look up and consume the challenge nonce (single-use)
|
|
206
|
+
const challengeResult = await ddb.send(
|
|
207
|
+
new GetCommand({
|
|
208
|
+
TableName: tableName,
|
|
209
|
+
Key: {
|
|
210
|
+
PK: `CHALLENGE#${request.challengeNonce}`,
|
|
211
|
+
SK: 'META',
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (!challengeResult.Item) {
|
|
217
|
+
return {
|
|
218
|
+
statusCode: 400,
|
|
219
|
+
headers: JSON_HEADERS,
|
|
220
|
+
body: JSON.stringify({ error: 'invalid_challenge', message: 'Challenge not found or already used' }),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check challenge expiry
|
|
225
|
+
if (new Date(challengeResult.Item['expiresAt'] as string) < new Date()) {
|
|
226
|
+
// Clean up expired challenge
|
|
227
|
+
await ddb.send(
|
|
228
|
+
new DeleteCommand({
|
|
229
|
+
TableName: tableName,
|
|
230
|
+
Key: { PK: `CHALLENGE#${request.challengeNonce}`, SK: 'META' },
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
return {
|
|
234
|
+
statusCode: 400,
|
|
235
|
+
headers: JSON_HEADERS,
|
|
236
|
+
body: JSON.stringify({ error: 'challenge_expired', message: 'Challenge has expired' }),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Consume the challenge (delete it — single-use)
|
|
241
|
+
await ddb.send(
|
|
242
|
+
new DeleteCommand({
|
|
243
|
+
TableName: tableName,
|
|
244
|
+
Key: { PK: `CHALLENGE#${request.challengeNonce}`, SK: 'META' },
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Verify the SSH signature of the challenge nonce against the public key
|
|
249
|
+
const validSignature = verifyRegistrationSignature(
|
|
250
|
+
request.publicKey,
|
|
251
|
+
request.challengeNonce,
|
|
252
|
+
request.signedChallenge,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (!validSignature) {
|
|
256
|
+
logger.warn('Registration signature verification failed');
|
|
257
|
+
return {
|
|
258
|
+
statusCode: 401,
|
|
259
|
+
headers: JSON_HEADERS,
|
|
260
|
+
body: JSON.stringify({ error: 'invalid_signature', message: 'Challenge signature verification failed' }),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// GitHub verification (if --github was provided)
|
|
265
|
+
if (request.github) {
|
|
266
|
+
try {
|
|
267
|
+
const keyOnGitHub = await verifyKeyOnGitHub(request.publicKey, request.github);
|
|
268
|
+
if (!keyOnGitHub) {
|
|
269
|
+
return {
|
|
270
|
+
statusCode: 400,
|
|
271
|
+
headers: JSON_HEADERS,
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
error: 'github_key_not_found',
|
|
274
|
+
message: `Public key not found on GitHub account "${request.github}"`,
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (err instanceof GitHubVerificationError) {
|
|
280
|
+
return {
|
|
281
|
+
statusCode: 400,
|
|
282
|
+
headers: JSON_HEADERS,
|
|
283
|
+
body: JSON.stringify({ error: err.code, message: err.message }),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check if an existing tenant is associated with this GitHub username (auto-link)
|
|
290
|
+
const existingTenantId = await findTenantByGitHub(request.github, ddb, tableName);
|
|
291
|
+
if (existingTenantId) {
|
|
292
|
+
logger.info('GitHub auto-link: existing tenant found', {
|
|
293
|
+
existingTenantId,
|
|
294
|
+
github: request.github,
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
statusCode: 200,
|
|
298
|
+
headers: JSON_HEADERS,
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
status: 'auto_linked',
|
|
301
|
+
tenantId: existingTenantId,
|
|
302
|
+
github: request.github,
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const tenantId = tenantIdFromPublicKey(request.publicKey);
|
|
309
|
+
const now = new Date().toISOString();
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
await ddb.send(
|
|
313
|
+
new PutCommand({
|
|
314
|
+
TableName: tableName,
|
|
315
|
+
Item: {
|
|
316
|
+
PK: `TENANT#${tenantId}`,
|
|
317
|
+
SK: 'META',
|
|
318
|
+
publicKey: request.publicKey,
|
|
319
|
+
createdAt: now,
|
|
320
|
+
updatedAt: now,
|
|
321
|
+
storageUsedBytes: 0,
|
|
322
|
+
},
|
|
323
|
+
ConditionExpression: 'attribute_not_exists(SK)',
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
logger.info('Tenant registered', { tenantId, operation: 'register' });
|
|
328
|
+
|
|
329
|
+
// Store GitHub association if provided
|
|
330
|
+
if (request.github) {
|
|
331
|
+
await storeGitHubAssociation(tenantId, request.github, ddb, tableName);
|
|
332
|
+
await storeGitHubReverseLookup(request.github, tenantId, ddb, tableName);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await logAuditEvent(ddb, tableName, tenantId, {
|
|
336
|
+
eventType: 'registered',
|
|
337
|
+
fingerprint: '',
|
|
338
|
+
metadata: {
|
|
339
|
+
publicKey: request.publicKey,
|
|
340
|
+
...(request.github && { github: request.github }),
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
statusCode: 201,
|
|
346
|
+
headers: JSON_HEADERS,
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
tenantId,
|
|
349
|
+
publicKey: request.publicKey,
|
|
350
|
+
...(request.github && { github: request.github }),
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
} catch (err: unknown) {
|
|
354
|
+
if ((err as { name?: string }).name === 'ConditionalCheckFailedException') {
|
|
355
|
+
return {
|
|
356
|
+
statusCode: 409,
|
|
357
|
+
headers: JSON_HEADERS,
|
|
358
|
+
body: JSON.stringify({ error: 'already_registered', message: 'This public key is already registered' }),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
2
|
+
interface HandlerResponse {
|
|
3
|
+
statusCode: number;
|
|
4
|
+
body: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export declare function handleRestore(blobId: string, tenantId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=restore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"restore.d.ts","sourceRoot":"","sources":["../../../../lib/handler/routes/restore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAA6B,MAAM,uBAAuB,CAAC;AAG1F,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CA0D1B"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleRestore = handleRestore;
|
|
4
|
+
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
5
|
+
const logger_js_1 = require("../logger.js");
|
|
6
|
+
async function handleRestore(blobId, tenantId, ddb, tableName) {
|
|
7
|
+
// Get the blob to verify it exists and is deleted
|
|
8
|
+
const existing = await ddb.send(new lib_dynamodb_1.GetCommand({
|
|
9
|
+
TableName: tableName,
|
|
10
|
+
Key: { PK: `TENANT#${tenantId}`, SK: `BLOB#${blobId}` },
|
|
11
|
+
ProjectionExpression: 'deletedAt, #s',
|
|
12
|
+
ExpressionAttributeNames: { '#s': 'size' },
|
|
13
|
+
}));
|
|
14
|
+
if (!existing.Item) {
|
|
15
|
+
return {
|
|
16
|
+
statusCode: 404,
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify({ error: 'not_found', message: 'Blob not found' }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (!existing.Item['deletedAt']) {
|
|
22
|
+
return {
|
|
23
|
+
statusCode: 400,
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ error: 'not_deleted', message: 'Blob is not deleted' }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const blobSize = existing.Item['size'] ?? 0;
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
// Remove deletedAt and ttl
|
|
31
|
+
await ddb.send(new lib_dynamodb_1.UpdateCommand({
|
|
32
|
+
TableName: tableName,
|
|
33
|
+
Key: { PK: `TENANT#${tenantId}`, SK: `BLOB#${blobId}` },
|
|
34
|
+
UpdateExpression: 'REMOVE deletedAt, #ttl SET updatedAt = :updatedAt',
|
|
35
|
+
ExpressionAttributeNames: { '#ttl': 'ttl' },
|
|
36
|
+
ExpressionAttributeValues: { ':updatedAt': now },
|
|
37
|
+
}));
|
|
38
|
+
// Re-increment storage used
|
|
39
|
+
await ddb.send(new lib_dynamodb_1.UpdateCommand({
|
|
40
|
+
TableName: tableName,
|
|
41
|
+
Key: { PK: `TENANT#${tenantId}`, SK: 'META' },
|
|
42
|
+
UpdateExpression: 'SET storageUsedBytes = storageUsedBytes + :size',
|
|
43
|
+
ExpressionAttributeValues: { ':size': blobSize },
|
|
44
|
+
}));
|
|
45
|
+
logger_js_1.logger.info('Blob restored', { tenantId, operation: 'RESTORE' });
|
|
46
|
+
return {
|
|
47
|
+
statusCode: 200,
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ id: blobId, restored: true }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=restore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"restore.js","sourceRoot":"","sources":["../../../../lib/handler/routes/restore.ts"],"names":[],"mappings":";;AASA,sCA+DC;AAxED,wDAA0F;AAC1F,4CAAsC;AAQ/B,KAAK,UAAU,aAAa,CACjC,MAAc,EACd,QAAgB,EAChB,GAA2B,EAC3B,SAAiB;IAEjB,kDAAkD;IAClD,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,IAAI,CAC7B,IAAI,yBAAU,CAAC;QACb,SAAS,EAAE,SAAS;QACpB,GAAG,EAAE,EAAE,EAAE,EAAE,UAAU,QAAQ,EAAE,EAAE,EAAE,EAAE,QAAQ,MAAM,EAAE,EAAE;QACvD,oBAAoB,EAAE,eAAe;QACrC,wBAAwB,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;KAC3C,CAAC,CACH,CAAC;IAEF,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;QACnB,OAAO;YACL,UAAU,EAAE,GAAG;YACf,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,gBAAgB,EAAE,CAAC;SACxE,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;QAChC,OAAO;YACL,UAAU,EAAE,GAAG;YACf,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC;SAC/E,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAI,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAY,IAAI,CAAC,CAAC;IACxD,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAErC,2BAA2B;IAC3B,MAAM,GAAG,CAAC,IAAI,CACZ,IAAI,4BAAa,CAAC;QAChB,SAAS,EAAE,SAAS;QACpB,GAAG,EAAE,EAAE,EAAE,EAAE,UAAU,QAAQ,EAAE,EAAE,EAAE,EAAE,QAAQ,MAAM,EAAE,EAAE;QACvD,gBAAgB,EAAE,mDAAmD;QACrE,wBAAwB,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE;QAC3C,yBAAyB,EAAE,EAAE,YAAY,EAAE,GAAG,EAAE;KACjD,CAAC,CACH,CAAC;IAEF,4BAA4B;IAC5B,MAAM,GAAG,CAAC,IAAI,CACZ,IAAI,4BAAa,CAAC;QAChB,SAAS,EAAE,SAAS;QACpB,GAAG,EAAE,EAAE,EAAE,EAAE,UAAU,QAAQ,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;QAC7C,gBAAgB,EAAE,iDAAiD;QACnE,yBAAyB,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE;KACjD,CAAC,CACH,CAAC;IAEF,kBAAM,CAAC,IAAI,CAAC,eAAe,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC,CAAC;IAEjE,OAAO;QACL,UAAU,EAAE,GAAG;QACf,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;KACrD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DynamoDBDocumentClient, UpdateCommand, GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
2
|
+
import { logger } from '../logger.js';
|
|
3
|
+
|
|
4
|
+
interface HandlerResponse {
|
|
5
|
+
statusCode: number;
|
|
6
|
+
body: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function handleRestore(
|
|
11
|
+
blobId: string,
|
|
12
|
+
tenantId: string,
|
|
13
|
+
ddb: DynamoDBDocumentClient,
|
|
14
|
+
tableName: string,
|
|
15
|
+
): Promise<HandlerResponse> {
|
|
16
|
+
// Get the blob to verify it exists and is deleted
|
|
17
|
+
const existing = await ddb.send(
|
|
18
|
+
new GetCommand({
|
|
19
|
+
TableName: tableName,
|
|
20
|
+
Key: { PK: `TENANT#${tenantId}`, SK: `BLOB#${blobId}` },
|
|
21
|
+
ProjectionExpression: 'deletedAt, #s',
|
|
22
|
+
ExpressionAttributeNames: { '#s': 'size' },
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!existing.Item) {
|
|
27
|
+
return {
|
|
28
|
+
statusCode: 404,
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ error: 'not_found', message: 'Blob not found' }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!existing.Item['deletedAt']) {
|
|
35
|
+
return {
|
|
36
|
+
statusCode: 400,
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ error: 'not_deleted', message: 'Blob is not deleted' }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const blobSize = (existing.Item['size'] as number) ?? 0;
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
|
|
45
|
+
// Remove deletedAt and ttl
|
|
46
|
+
await ddb.send(
|
|
47
|
+
new UpdateCommand({
|
|
48
|
+
TableName: tableName,
|
|
49
|
+
Key: { PK: `TENANT#${tenantId}`, SK: `BLOB#${blobId}` },
|
|
50
|
+
UpdateExpression: 'REMOVE deletedAt, #ttl SET updatedAt = :updatedAt',
|
|
51
|
+
ExpressionAttributeNames: { '#ttl': 'ttl' },
|
|
52
|
+
ExpressionAttributeValues: { ':updatedAt': now },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Re-increment storage used
|
|
57
|
+
await ddb.send(
|
|
58
|
+
new UpdateCommand({
|
|
59
|
+
TableName: tableName,
|
|
60
|
+
Key: { PK: `TENANT#${tenantId}`, SK: 'META' },
|
|
61
|
+
UpdateExpression: 'SET storageUsedBytes = storageUsedBytes + :size',
|
|
62
|
+
ExpressionAttributeValues: { ':size': blobSize },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
logger.info('Blob restored', { tenantId, operation: 'RESTORE' });
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
statusCode: 200,
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ id: blobId, restored: true }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
2
|
+
interface HandlerResponse {
|
|
3
|
+
statusCode: number;
|
|
4
|
+
body: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Emergency revocation: delete all KEY#, WRAPPED_KEY#, ROTATION, and SEQUENCE# items
|
|
9
|
+
* for a tenant, then log an audit event.
|
|
10
|
+
*/
|
|
11
|
+
export declare function handleRevokeAll(tenantId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=revocation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation.d.ts","sourceRoot":"","sources":["../../../../lib/handler/routes/revocation.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EAGvB,MAAM,uBAAuB,CAAC;AAI/B,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAgE1B"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleRevokeAll = handleRevokeAll;
|
|
4
|
+
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
5
|
+
const audit_js_1 = require("./audit.js");
|
|
6
|
+
const logger_js_1 = require("../logger.js");
|
|
7
|
+
/**
|
|
8
|
+
* Emergency revocation: delete all KEY#, WRAPPED_KEY#, ROTATION, and SEQUENCE# items
|
|
9
|
+
* for a tenant, then log an audit event.
|
|
10
|
+
*/
|
|
11
|
+
async function handleRevokeAll(tenantId, ddb, tableName) {
|
|
12
|
+
const pk = `TENANT#${tenantId}`;
|
|
13
|
+
// Query all items in the tenant partition
|
|
14
|
+
const result = await ddb.send(new lib_dynamodb_1.QueryCommand({
|
|
15
|
+
TableName: tableName,
|
|
16
|
+
KeyConditionExpression: 'PK = :pk',
|
|
17
|
+
ExpressionAttributeValues: {
|
|
18
|
+
':pk': pk,
|
|
19
|
+
},
|
|
20
|
+
ProjectionExpression: 'PK, SK',
|
|
21
|
+
}));
|
|
22
|
+
const allItems = result.Items ?? [];
|
|
23
|
+
// Filter to revocable SK prefixes
|
|
24
|
+
const toDelete = allItems.filter((item) => {
|
|
25
|
+
const sk = item['SK'];
|
|
26
|
+
return (sk.startsWith('KEY#') ||
|
|
27
|
+
sk.startsWith('WRAPPED_KEY#') ||
|
|
28
|
+
sk === 'ROTATION' ||
|
|
29
|
+
sk.startsWith('SEQUENCE#'));
|
|
30
|
+
});
|
|
31
|
+
// Batch delete in groups of 25
|
|
32
|
+
for (let i = 0; i < toDelete.length; i += 25) {
|
|
33
|
+
const batch = toDelete.slice(i, i + 25);
|
|
34
|
+
await ddb.send(new lib_dynamodb_1.BatchWriteCommand({
|
|
35
|
+
RequestItems: {
|
|
36
|
+
[tableName]: batch.map((item) => ({
|
|
37
|
+
DeleteRequest: {
|
|
38
|
+
Key: { PK: item['PK'], SK: item['SK'] },
|
|
39
|
+
},
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
logger_js_1.logger.info('Emergency revocation completed', {
|
|
45
|
+
tenantId,
|
|
46
|
+
deletedItems: toDelete.length,
|
|
47
|
+
});
|
|
48
|
+
// Log audit event
|
|
49
|
+
await (0, audit_js_1.logAuditEvent)(ddb, tableName, tenantId, {
|
|
50
|
+
eventType: 'revoked',
|
|
51
|
+
fingerprint: 'all',
|
|
52
|
+
metadata: { deletedItems: toDelete.length },
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
statusCode: 200,
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
status: 'revoked',
|
|
59
|
+
message: 'All devices revoked. Re-register with a new SSH key.',
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=revocation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation.js","sourceRoot":"","sources":["../../../../lib/handler/routes/revocation.ts"],"names":[],"mappings":";;AAkBA,0CAoEC;AAtFD,wDAI+B;AAC/B,yCAA2C;AAC3C,4CAAsC;AAQtC;;;GAGG;AACI,KAAK,UAAU,eAAe,CACnC,QAAgB,EAChB,GAA2B,EAC3B,SAAiB;IAEjB,MAAM,EAAE,GAAG,UAAU,QAAQ,EAAE,CAAC;IAEhC,0CAA0C;IAC1C,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,IAAI,CAC3B,IAAI,2BAAY,CAAC;QACf,SAAS,EAAE,SAAS;QACpB,sBAAsB,EAAE,UAAU;QAClC,yBAAyB,EAAE;YACzB,KAAK,EAAE,EAAE;SACV;QACD,oBAAoB,EAAE,QAAQ;KAC/B,CAAC,CACH,CAAC;IAEF,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;IAEpC,kCAAkC;IAClC,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE;QACxC,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAW,CAAC;QAChC,OAAO,CACL,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC;YACrB,EAAE,CAAC,UAAU,CAAC,cAAc,CAAC;YAC7B,EAAE,KAAK,UAAU;YACjB,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,CAC3B,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,+BAA+B;IAC/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC;QAC7C,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;QACxC,MAAM,GAAG,CAAC,IAAI,CACZ,IAAI,gCAAiB,CAAC;YACpB,YAAY,EAAE;gBACZ,CAAC,SAAS,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;oBAChC,aAAa,EAAE;wBACb,GAAG,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE;qBACxC;iBACF,CAAC,CAAC;aACJ;SACF,CAAC,CACH,CAAC;IACJ,CAAC;IAED,kBAAM,CAAC,IAAI,CAAC,gCAAgC,EAAE;QAC5C,QAAQ;QACR,YAAY,EAAE,QAAQ,CAAC,MAAM;KAC9B,CAAC,CAAC;IAEH,kBAAkB;IAClB,MAAM,IAAA,wBAAa,EAAC,GAAG,EAAE,SAAS,EAAE,QAAQ,EAAE;QAC5C,SAAS,EAAE,SAAS;QACpB,WAAW,EAAE,KAAK;QAClB,QAAQ,EAAE,EAAE,YAAY,EAAE,QAAQ,CAAC,MAAM,EAAE;KAC5C,CAAC,CAAC;IAEH,OAAO;QACL,UAAU,EAAE,GAAG;QACf,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,MAAM,EAAE,SAAS;YACjB,OAAO,EAAE,sDAAsD;SAChE,CAAC;KACH,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamoDBDocumentClient,
|
|
3
|
+
QueryCommand,
|
|
4
|
+
BatchWriteCommand,
|
|
5
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
6
|
+
import { logAuditEvent } from './audit.js';
|
|
7
|
+
import { logger } from '../logger.js';
|
|
8
|
+
|
|
9
|
+
interface HandlerResponse {
|
|
10
|
+
statusCode: number;
|
|
11
|
+
body: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Emergency revocation: delete all KEY#, WRAPPED_KEY#, ROTATION, and SEQUENCE# items
|
|
17
|
+
* for a tenant, then log an audit event.
|
|
18
|
+
*/
|
|
19
|
+
export async function handleRevokeAll(
|
|
20
|
+
tenantId: string,
|
|
21
|
+
ddb: DynamoDBDocumentClient,
|
|
22
|
+
tableName: string,
|
|
23
|
+
): Promise<HandlerResponse> {
|
|
24
|
+
const pk = `TENANT#${tenantId}`;
|
|
25
|
+
|
|
26
|
+
// Query all items in the tenant partition
|
|
27
|
+
const result = await ddb.send(
|
|
28
|
+
new QueryCommand({
|
|
29
|
+
TableName: tableName,
|
|
30
|
+
KeyConditionExpression: 'PK = :pk',
|
|
31
|
+
ExpressionAttributeValues: {
|
|
32
|
+
':pk': pk,
|
|
33
|
+
},
|
|
34
|
+
ProjectionExpression: 'PK, SK',
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const allItems = result.Items ?? [];
|
|
39
|
+
|
|
40
|
+
// Filter to revocable SK prefixes
|
|
41
|
+
const toDelete = allItems.filter((item) => {
|
|
42
|
+
const sk = item['SK'] as string;
|
|
43
|
+
return (
|
|
44
|
+
sk.startsWith('KEY#') ||
|
|
45
|
+
sk.startsWith('WRAPPED_KEY#') ||
|
|
46
|
+
sk === 'ROTATION' ||
|
|
47
|
+
sk.startsWith('SEQUENCE#')
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Batch delete in groups of 25
|
|
52
|
+
for (let i = 0; i < toDelete.length; i += 25) {
|
|
53
|
+
const batch = toDelete.slice(i, i + 25);
|
|
54
|
+
await ddb.send(
|
|
55
|
+
new BatchWriteCommand({
|
|
56
|
+
RequestItems: {
|
|
57
|
+
[tableName]: batch.map((item) => ({
|
|
58
|
+
DeleteRequest: {
|
|
59
|
+
Key: { PK: item['PK'], SK: item['SK'] },
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.info('Emergency revocation completed', {
|
|
68
|
+
tenantId,
|
|
69
|
+
deletedItems: toDelete.length,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Log audit event
|
|
73
|
+
await logAuditEvent(ddb, tableName, tenantId, {
|
|
74
|
+
eventType: 'revoked',
|
|
75
|
+
fingerprint: 'all',
|
|
76
|
+
metadata: { deletedItems: toDelete.length },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
statusCode: 200,
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
status: 'revoked',
|
|
84
|
+
message: 'All devices revoked. Re-register with a new SSH key.',
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|