@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.
Files changed (157) hide show
  1. package/dist/lib/admin-handler/index.d.ts +22 -0
  2. package/dist/lib/admin-handler/index.d.ts.map +1 -0
  3. package/dist/lib/admin-handler/index.js +92 -0
  4. package/dist/lib/admin-handler/index.js.map +1 -0
  5. package/dist/lib/admin-handler/index.ts +123 -0
  6. package/dist/lib/admin-handler/routes/metrics.d.ts +11 -0
  7. package/dist/lib/admin-handler/routes/metrics.d.ts.map +1 -0
  8. package/dist/lib/admin-handler/routes/metrics.js +200 -0
  9. package/dist/lib/admin-handler/routes/metrics.js.map +1 -0
  10. package/dist/lib/admin-handler/routes/metrics.ts +234 -0
  11. package/dist/lib/admin-handler/routes/overview.d.ts +9 -0
  12. package/dist/lib/admin-handler/routes/overview.d.ts.map +1 -0
  13. package/dist/lib/admin-handler/routes/overview.js +110 -0
  14. package/dist/lib/admin-handler/routes/overview.js.map +1 -0
  15. package/dist/lib/admin-handler/routes/overview.ts +133 -0
  16. package/dist/lib/admin-handler/routes/tenants.d.ts +10 -0
  17. package/dist/lib/admin-handler/routes/tenants.d.ts.map +1 -0
  18. package/dist/lib/admin-handler/routes/tenants.js +108 -0
  19. package/dist/lib/admin-handler/routes/tenants.js.map +1 -0
  20. package/dist/lib/admin-handler/routes/tenants.ts +134 -0
  21. package/dist/lib/chaoskb-stack.d.ts +22 -0
  22. package/dist/lib/chaoskb-stack.d.ts.map +1 -0
  23. package/dist/lib/chaoskb-stack.js +60 -0
  24. package/dist/lib/chaoskb-stack.js.map +1 -0
  25. package/dist/lib/constructs/admin-api.d.ts +16 -0
  26. package/dist/lib/constructs/admin-api.d.ts.map +1 -0
  27. package/dist/lib/constructs/admin-api.js +93 -0
  28. package/dist/lib/constructs/admin-api.js.map +1 -0
  29. package/dist/lib/constructs/admin-dashboard.d.ts +18 -0
  30. package/dist/lib/constructs/admin-dashboard.d.ts.map +1 -0
  31. package/dist/lib/constructs/admin-dashboard.js +172 -0
  32. package/dist/lib/constructs/admin-dashboard.js.map +1 -0
  33. package/dist/lib/constructs/api.d.ts +17 -0
  34. package/dist/lib/constructs/api.d.ts.map +1 -0
  35. package/dist/lib/constructs/api.js +81 -0
  36. package/dist/lib/constructs/api.js.map +1 -0
  37. package/dist/lib/constructs/auth.d.ts +11 -0
  38. package/dist/lib/constructs/auth.d.ts.map +1 -0
  39. package/dist/lib/constructs/auth.js +18 -0
  40. package/dist/lib/constructs/auth.js.map +1 -0
  41. package/dist/lib/constructs/blob-store.d.ts +10 -0
  42. package/dist/lib/constructs/blob-store.d.ts.map +1 -0
  43. package/dist/lib/constructs/blob-store.js +31 -0
  44. package/dist/lib/constructs/blob-store.js.map +1 -0
  45. package/dist/lib/deploy-cli.d.ts +3 -0
  46. package/dist/lib/deploy-cli.d.ts.map +1 -0
  47. package/dist/lib/deploy-cli.js +49 -0
  48. package/dist/lib/deploy-cli.js.map +1 -0
  49. package/dist/lib/handler/index.d.ts +23 -0
  50. package/dist/lib/handler/index.d.ts.map +1 -0
  51. package/dist/lib/handler/index.js +276 -0
  52. package/dist/lib/handler/index.js.map +1 -0
  53. package/dist/lib/handler/index.ts +372 -0
  54. package/dist/lib/handler/logger.d.ts +16 -0
  55. package/dist/lib/handler/logger.d.ts.map +1 -0
  56. package/dist/lib/handler/logger.js +26 -0
  57. package/dist/lib/handler/logger.js.map +1 -0
  58. package/dist/lib/handler/logger.ts +36 -0
  59. package/dist/lib/handler/middleware/input-validation.d.ts +6 -0
  60. package/dist/lib/handler/middleware/input-validation.d.ts.map +1 -0
  61. package/dist/lib/handler/middleware/input-validation.js +36 -0
  62. package/dist/lib/handler/middleware/input-validation.js.map +1 -0
  63. package/dist/lib/handler/middleware/input-validation.ts +44 -0
  64. package/dist/lib/handler/middleware/rate-limit.d.ts +14 -0
  65. package/dist/lib/handler/middleware/rate-limit.d.ts.map +1 -0
  66. package/dist/lib/handler/middleware/rate-limit.js +94 -0
  67. package/dist/lib/handler/middleware/rate-limit.js.map +1 -0
  68. package/dist/lib/handler/middleware/rate-limit.ts +121 -0
  69. package/dist/lib/handler/middleware/ssh-auth.d.ts +48 -0
  70. package/dist/lib/handler/middleware/ssh-auth.d.ts.map +1 -0
  71. package/dist/lib/handler/middleware/ssh-auth.js +256 -0
  72. package/dist/lib/handler/middleware/ssh-auth.js.map +1 -0
  73. package/dist/lib/handler/middleware/ssh-auth.ts +300 -0
  74. package/dist/lib/handler/routes/audit.d.ts +24 -0
  75. package/dist/lib/handler/routes/audit.d.ts.map +1 -0
  76. package/dist/lib/handler/routes/audit.js +94 -0
  77. package/dist/lib/handler/routes/audit.js.map +1 -0
  78. package/dist/lib/handler/routes/audit.ts +101 -0
  79. package/dist/lib/handler/routes/blobs.d.ts +13 -0
  80. package/dist/lib/handler/routes/blobs.d.ts.map +1 -0
  81. package/dist/lib/handler/routes/blobs.js +298 -0
  82. package/dist/lib/handler/routes/blobs.js.map +1 -0
  83. package/dist/lib/handler/routes/blobs.ts +348 -0
  84. package/dist/lib/handler/routes/devices.d.ts +48 -0
  85. package/dist/lib/handler/routes/devices.d.ts.map +1 -0
  86. package/dist/lib/handler/routes/devices.js +394 -0
  87. package/dist/lib/handler/routes/devices.js.map +1 -0
  88. package/dist/lib/handler/routes/devices.ts +458 -0
  89. package/dist/lib/handler/routes/export.d.ts +9 -0
  90. package/dist/lib/handler/routes/export.d.ts.map +1 -0
  91. package/dist/lib/handler/routes/export.js +40 -0
  92. package/dist/lib/handler/routes/export.js.map +1 -0
  93. package/dist/lib/handler/routes/export.ts +55 -0
  94. package/dist/lib/handler/routes/github.d.ts +31 -0
  95. package/dist/lib/handler/routes/github.d.ts.map +1 -0
  96. package/dist/lib/handler/routes/github.js +118 -0
  97. package/dist/lib/handler/routes/github.js.map +1 -0
  98. package/dist/lib/handler/routes/github.ts +162 -0
  99. package/dist/lib/handler/routes/health.d.ts +6 -0
  100. package/dist/lib/handler/routes/health.d.ts.map +1 -0
  101. package/dist/lib/handler/routes/health.js +14 -0
  102. package/dist/lib/handler/routes/health.js.map +1 -0
  103. package/dist/lib/handler/routes/health.ts +10 -0
  104. package/dist/lib/handler/routes/invites.d.ts +24 -0
  105. package/dist/lib/handler/routes/invites.d.ts.map +1 -0
  106. package/dist/lib/handler/routes/invites.js +445 -0
  107. package/dist/lib/handler/routes/invites.js.map +1 -0
  108. package/dist/lib/handler/routes/invites.ts +527 -0
  109. package/dist/lib/handler/routes/notifications.d.ts +39 -0
  110. package/dist/lib/handler/routes/notifications.d.ts.map +1 -0
  111. package/dist/lib/handler/routes/notifications.js +150 -0
  112. package/dist/lib/handler/routes/notifications.js.map +1 -0
  113. package/dist/lib/handler/routes/notifications.ts +163 -0
  114. package/dist/lib/handler/routes/projects.d.ts +24 -0
  115. package/dist/lib/handler/routes/projects.d.ts.map +1 -0
  116. package/dist/lib/handler/routes/projects.js +47 -0
  117. package/dist/lib/handler/routes/projects.js.map +1 -0
  118. package/dist/lib/handler/routes/projects.ts +69 -0
  119. package/dist/lib/handler/routes/register.d.ts +19 -0
  120. package/dist/lib/handler/routes/register.d.ts.map +1 -0
  121. package/dist/lib/handler/routes/register.js +327 -0
  122. package/dist/lib/handler/routes/register.js.map +1 -0
  123. package/dist/lib/handler/routes/register.ts +363 -0
  124. package/dist/lib/handler/routes/restore.d.ts +9 -0
  125. package/dist/lib/handler/routes/restore.d.ts.map +1 -0
  126. package/dist/lib/handler/routes/restore.js +52 -0
  127. package/dist/lib/handler/routes/restore.js.map +1 -0
  128. package/dist/lib/handler/routes/restore.ts +73 -0
  129. package/dist/lib/handler/routes/revocation.d.ts +13 -0
  130. package/dist/lib/handler/routes/revocation.d.ts.map +1 -0
  131. package/dist/lib/handler/routes/revocation.js +63 -0
  132. package/dist/lib/handler/routes/revocation.js.map +1 -0
  133. package/dist/lib/handler/routes/revocation.ts +87 -0
  134. package/dist/lib/handler/routes/rotation.d.ts +24 -0
  135. package/dist/lib/handler/routes/rotation.d.ts.map +1 -0
  136. package/dist/lib/handler/routes/rotation.js +291 -0
  137. package/dist/lib/handler/routes/rotation.js.map +1 -0
  138. package/dist/lib/handler/routes/rotation.ts +336 -0
  139. package/dist/lib/handler/routes/tenants.d.ts +11 -0
  140. package/dist/lib/handler/routes/tenants.d.ts.map +1 -0
  141. package/dist/lib/handler/routes/tenants.js +181 -0
  142. package/dist/lib/handler/routes/tenants.js.map +1 -0
  143. package/dist/lib/handler/routes/tenants.ts +198 -0
  144. package/dist/lib/handler/routes/wrapped-key.d.ts +21 -0
  145. package/dist/lib/handler/routes/wrapped-key.d.ts.map +1 -0
  146. package/dist/lib/handler/routes/wrapped-key.js +76 -0
  147. package/dist/lib/handler/routes/wrapped-key.js.map +1 -0
  148. package/dist/lib/handler/routes/wrapped-key.ts +108 -0
  149. package/dist/lib/index.d.ts +7 -0
  150. package/dist/lib/index.d.ts.map +1 -0
  151. package/dist/lib/index.js +16 -0
  152. package/dist/lib/index.js.map +1 -0
  153. package/dist/vitest.config.d.ts +3 -0
  154. package/dist/vitest.config.d.ts.map +1 -0
  155. package/dist/vitest.config.js +18 -0
  156. package/dist/vitest.config.js.map +1 -0
  157. package/package.json +61 -0
@@ -0,0 +1,24 @@
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
+ * POST /v1/rotate-start — Phase 1 of two-phase key rotation.
9
+ *
10
+ * Authenticated with the OLD key. Stores the new public key alongside the
11
+ * old key and marks the rotation as started. Both old and new keys are
12
+ * accepted for authentication going forward.
13
+ */
14
+ export declare function handleRotateStart(tenantId: string, oldFingerprint: string, rawBody: string | null | undefined, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
15
+ /**
16
+ * POST /v1/rotate-confirm — Phase 2 of two-phase key rotation.
17
+ *
18
+ * Authenticated with the NEW key. Adds the device to the confirmations list.
19
+ * When all registered devices have confirmed (or the 48h timeout has elapsed),
20
+ * completes the rotation by removing the old key and rotation record.
21
+ */
22
+ export declare function handleRotateConfirm(tenantId: string, fingerprint: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
23
+ export {};
24
+ //# sourceMappingURL=rotation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotation.d.ts","sourceRoot":"","sources":["../../../../lib/handler/routes/rotation.ts"],"names":[],"mappings":"AACA,OAAO,EACL,sBAAsB,EAMvB,MAAM,uBAAuB,CAAC;AAG/B,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AA6BD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAClC,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAoH1B;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CA6F1B"}
@@ -0,0 +1,291 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.handleRotateStart = handleRotateStart;
37
+ exports.handleRotateConfirm = handleRotateConfirm;
38
+ const crypto = __importStar(require("crypto"));
39
+ const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
40
+ const logger_js_1 = require("../logger.js");
41
+ /** 48 hours in milliseconds — timeout for rotation confirmation phase. */
42
+ const ROTATION_TIMEOUT_MS = 48 * 60 * 60 * 1000;
43
+ function fingerprintFromPublicKey(publicKeyBase64) {
44
+ return crypto.createHash('sha256').update(Buffer.from(publicKeyBase64, 'base64')).digest('base64');
45
+ }
46
+ function tenantIdFromPublicKey(publicKeyBase64) {
47
+ const hash = crypto.createHash('sha256').update(publicKeyBase64).digest('hex');
48
+ return hash.slice(0, 32);
49
+ }
50
+ function isValidBase64(value) {
51
+ if (!value || value.length < 4 || value.length > 8192)
52
+ return false;
53
+ try {
54
+ const decoded = Buffer.from(value, 'base64');
55
+ return decoded.length > 0;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ /**
62
+ * POST /v1/rotate-start — Phase 1 of two-phase key rotation.
63
+ *
64
+ * Authenticated with the OLD key. Stores the new public key alongside the
65
+ * old key and marks the rotation as started. Both old and new keys are
66
+ * accepted for authentication going forward.
67
+ */
68
+ async function handleRotateStart(tenantId, oldFingerprint, rawBody, ddb, tableName) {
69
+ if (!rawBody) {
70
+ return {
71
+ statusCode: 400,
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ error: 'invalid_request', message: 'Request body is required' }),
74
+ };
75
+ }
76
+ let request;
77
+ try {
78
+ request = JSON.parse(rawBody);
79
+ }
80
+ catch {
81
+ return {
82
+ statusCode: 400,
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ error: 'invalid_request', message: 'Invalid JSON body' }),
85
+ };
86
+ }
87
+ if (!request.newPublicKey || !isValidBase64(request.newPublicKey)) {
88
+ return {
89
+ statusCode: 400,
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ error: 'invalid_request', message: 'newPublicKey is required and must be valid base64' }),
92
+ };
93
+ }
94
+ if (!request.wrappedBlob || !isValidBase64(request.wrappedBlob)) {
95
+ return {
96
+ statusCode: 400,
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: JSON.stringify({ error: 'invalid_request', message: 'wrappedBlob is required and must be valid base64' }),
99
+ };
100
+ }
101
+ // Check for existing rotation in progress
102
+ const existingRotation = await ddb.send(new lib_dynamodb_1.GetCommand({
103
+ TableName: tableName,
104
+ Key: {
105
+ PK: `TENANT#${tenantId}`,
106
+ SK: 'ROTATION',
107
+ },
108
+ }));
109
+ if (existingRotation.Item) {
110
+ return {
111
+ statusCode: 409,
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ error: 'rotation_in_progress', message: 'A key rotation is already in progress' }),
114
+ };
115
+ }
116
+ const newFingerprint = fingerprintFromPublicKey(request.newPublicKey);
117
+ const now = new Date().toISOString();
118
+ // Store rotation record
119
+ await ddb.send(new lib_dynamodb_1.PutCommand({
120
+ TableName: tableName,
121
+ Item: {
122
+ PK: `TENANT#${tenantId}`,
123
+ SK: 'ROTATION',
124
+ phase: 'started',
125
+ newPublicKey: request.newPublicKey,
126
+ newFingerprint,
127
+ oldFingerprint,
128
+ startedAt: now,
129
+ confirmations: [],
130
+ },
131
+ }));
132
+ // Store the new public key in tenant META so auth middleware accepts both keys
133
+ await ddb.send(new lib_dynamodb_1.UpdateCommand({
134
+ TableName: tableName,
135
+ Key: {
136
+ PK: `TENANT#${tenantId}`,
137
+ SK: 'META',
138
+ },
139
+ UpdateExpression: 'SET newPublicKey = :npk, rotationState = :state, updatedAt = :now',
140
+ ExpressionAttributeValues: {
141
+ ':npk': request.newPublicKey,
142
+ ':state': 'ROTATION_STARTED',
143
+ ':now': now,
144
+ },
145
+ }));
146
+ // Store the wrapped blob for the new key
147
+ await ddb.send(new lib_dynamodb_1.PutCommand({
148
+ TableName: tableName,
149
+ Item: {
150
+ PK: `TENANT#${tenantId}`,
151
+ SK: `WRAPPED_KEY#${newFingerprint}`,
152
+ data: request.wrappedBlob,
153
+ updatedAt: now,
154
+ },
155
+ }));
156
+ logger_js_1.logger.info('Key rotation started', { tenantId, oldFingerprint, newFingerprint });
157
+ return {
158
+ statusCode: 200,
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ status: 'rotation_started',
162
+ newFingerprint,
163
+ oldFingerprint,
164
+ }),
165
+ };
166
+ }
167
+ /**
168
+ * POST /v1/rotate-confirm — Phase 2 of two-phase key rotation.
169
+ *
170
+ * Authenticated with the NEW key. Adds the device to the confirmations list.
171
+ * When all registered devices have confirmed (or the 48h timeout has elapsed),
172
+ * completes the rotation by removing the old key and rotation record.
173
+ */
174
+ async function handleRotateConfirm(tenantId, fingerprint, ddb, tableName) {
175
+ // Get the rotation record
176
+ const rotationResult = await ddb.send(new lib_dynamodb_1.GetCommand({
177
+ TableName: tableName,
178
+ Key: {
179
+ PK: `TENANT#${tenantId}`,
180
+ SK: 'ROTATION',
181
+ },
182
+ }));
183
+ if (!rotationResult.Item) {
184
+ return {
185
+ statusCode: 404,
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ error: 'not_found', message: 'No key rotation in progress' }),
188
+ };
189
+ }
190
+ const rotation = rotationResult.Item;
191
+ const confirmations = rotation['confirmations'] ?? [];
192
+ // Add this device fingerprint to confirmations if not already present
193
+ if (!confirmations.includes(fingerprint)) {
194
+ confirmations.push(fingerprint);
195
+ await ddb.send(new lib_dynamodb_1.UpdateCommand({
196
+ TableName: tableName,
197
+ Key: {
198
+ PK: `TENANT#${tenantId}`,
199
+ SK: 'ROTATION',
200
+ },
201
+ UpdateExpression: 'SET confirmations = :c',
202
+ ExpressionAttributeValues: {
203
+ ':c': confirmations,
204
+ },
205
+ }));
206
+ }
207
+ // Check if rotation should be completed:
208
+ // Either all devices confirmed or 48h timeout elapsed
209
+ const startedAt = new Date(rotation['startedAt']).getTime();
210
+ const timeoutReached = Date.now() - startedAt > ROTATION_TIMEOUT_MS;
211
+ // Count registered devices (WRAPPED_KEY# entries = registered devices)
212
+ const deviceResult = await ddb.send(new lib_dynamodb_1.QueryCommand({
213
+ TableName: tableName,
214
+ KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
215
+ ExpressionAttributeValues: {
216
+ ':pk': `TENANT#${tenantId}`,
217
+ ':prefix': 'WRAPPED_KEY#',
218
+ },
219
+ Select: 'COUNT',
220
+ }));
221
+ const deviceCount = deviceResult.Count ?? 0;
222
+ const allConfirmed = confirmations.length >= deviceCount;
223
+ if (allConfirmed || timeoutReached) {
224
+ // Complete the rotation: Phase 2
225
+ await completeRotation(tenantId, rotation, ddb, tableName);
226
+ logger_js_1.logger.info('Key rotation completed', {
227
+ tenantId,
228
+ newFingerprint: rotation['newFingerprint'],
229
+ reason: timeoutReached ? 'timeout' : 'all_confirmed',
230
+ });
231
+ return {
232
+ statusCode: 200,
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({
235
+ status: 'rotation_complete',
236
+ confirmations: confirmations.length,
237
+ totalDevices: deviceCount,
238
+ }),
239
+ };
240
+ }
241
+ return {
242
+ statusCode: 200,
243
+ headers: { 'Content-Type': 'application/json' },
244
+ body: JSON.stringify({
245
+ status: 'confirmation_recorded',
246
+ confirmations: confirmations.length,
247
+ totalDevices: deviceCount,
248
+ }),
249
+ };
250
+ }
251
+ /**
252
+ * Complete the rotation by:
253
+ * 1. Updating tenant META with the new public key as primary
254
+ * 2. Deleting old wrapped key blob
255
+ * 3. Deleting the rotation record
256
+ */
257
+ async function completeRotation(tenantId, rotation, ddb, tableName) {
258
+ const newPublicKey = rotation['newPublicKey'];
259
+ const oldFingerprint = rotation['oldFingerprint'];
260
+ const now = new Date().toISOString();
261
+ // Update tenant META: replace primary public key, clear rotation state
262
+ await ddb.send(new lib_dynamodb_1.UpdateCommand({
263
+ TableName: tableName,
264
+ Key: {
265
+ PK: `TENANT#${tenantId}`,
266
+ SK: 'META',
267
+ },
268
+ UpdateExpression: 'SET publicKey = :pk, updatedAt = :now REMOVE newPublicKey, rotationState',
269
+ ExpressionAttributeValues: {
270
+ ':pk': newPublicKey,
271
+ ':now': now,
272
+ },
273
+ }));
274
+ // Delete old wrapped key blob
275
+ await ddb.send(new lib_dynamodb_1.DeleteCommand({
276
+ TableName: tableName,
277
+ Key: {
278
+ PK: `TENANT#${tenantId}`,
279
+ SK: `WRAPPED_KEY#${oldFingerprint}`,
280
+ },
281
+ }));
282
+ // Delete rotation record
283
+ await ddb.send(new lib_dynamodb_1.DeleteCommand({
284
+ TableName: tableName,
285
+ Key: {
286
+ PK: `TENANT#${tenantId}`,
287
+ SK: 'ROTATION',
288
+ },
289
+ }));
290
+ }
291
+ //# sourceMappingURL=rotation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotation.js","sourceRoot":"","sources":["../../../../lib/handler/routes/rotation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,336 @@
1
+ import * as crypto from 'crypto';
2
+ import {
3
+ DynamoDBDocumentClient,
4
+ PutCommand,
5
+ GetCommand,
6
+ UpdateCommand,
7
+ DeleteCommand,
8
+ QueryCommand,
9
+ } from '@aws-sdk/lib-dynamodb';
10
+ import { logger } from '../logger.js';
11
+
12
+ interface HandlerResponse {
13
+ statusCode: number;
14
+ body: string;
15
+ headers: Record<string, string>;
16
+ }
17
+
18
+ interface RotateStartRequest {
19
+ newPublicKey: string;
20
+ wrappedBlob: string; // base64-encoded wrapped master key blob
21
+ }
22
+
23
+ /** 48 hours in milliseconds — timeout for rotation confirmation phase. */
24
+ const ROTATION_TIMEOUT_MS = 48 * 60 * 60 * 1000;
25
+
26
+ function fingerprintFromPublicKey(publicKeyBase64: string): string {
27
+ return crypto.createHash('sha256').update(Buffer.from(publicKeyBase64, 'base64')).digest('base64');
28
+ }
29
+
30
+ function tenantIdFromPublicKey(publicKeyBase64: string): string {
31
+ const hash = crypto.createHash('sha256').update(publicKeyBase64).digest('hex');
32
+ return hash.slice(0, 32);
33
+ }
34
+
35
+ function isValidBase64(value: string): boolean {
36
+ if (!value || value.length < 4 || value.length > 8192) return false;
37
+ try {
38
+ const decoded = Buffer.from(value, 'base64');
39
+ return decoded.length > 0;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * POST /v1/rotate-start — Phase 1 of two-phase key rotation.
47
+ *
48
+ * Authenticated with the OLD key. Stores the new public key alongside the
49
+ * old key and marks the rotation as started. Both old and new keys are
50
+ * accepted for authentication going forward.
51
+ */
52
+ export async function handleRotateStart(
53
+ tenantId: string,
54
+ oldFingerprint: string,
55
+ rawBody: string | null | undefined,
56
+ ddb: DynamoDBDocumentClient,
57
+ tableName: string,
58
+ ): Promise<HandlerResponse> {
59
+ if (!rawBody) {
60
+ return {
61
+ statusCode: 400,
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ error: 'invalid_request', message: 'Request body is required' }),
64
+ };
65
+ }
66
+
67
+ let request: RotateStartRequest;
68
+ try {
69
+ request = JSON.parse(rawBody);
70
+ } catch {
71
+ return {
72
+ statusCode: 400,
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ error: 'invalid_request', message: 'Invalid JSON body' }),
75
+ };
76
+ }
77
+
78
+ if (!request.newPublicKey || !isValidBase64(request.newPublicKey)) {
79
+ return {
80
+ statusCode: 400,
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ error: 'invalid_request', message: 'newPublicKey is required and must be valid base64' }),
83
+ };
84
+ }
85
+
86
+ if (!request.wrappedBlob || !isValidBase64(request.wrappedBlob)) {
87
+ return {
88
+ statusCode: 400,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ error: 'invalid_request', message: 'wrappedBlob is required and must be valid base64' }),
91
+ };
92
+ }
93
+
94
+ // Check for existing rotation in progress
95
+ const existingRotation = await ddb.send(
96
+ new GetCommand({
97
+ TableName: tableName,
98
+ Key: {
99
+ PK: `TENANT#${tenantId}`,
100
+ SK: 'ROTATION',
101
+ },
102
+ }),
103
+ );
104
+
105
+ if (existingRotation.Item) {
106
+ return {
107
+ statusCode: 409,
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({ error: 'rotation_in_progress', message: 'A key rotation is already in progress' }),
110
+ };
111
+ }
112
+
113
+ const newFingerprint = fingerprintFromPublicKey(request.newPublicKey);
114
+ const now = new Date().toISOString();
115
+
116
+ // Store rotation record
117
+ await ddb.send(
118
+ new PutCommand({
119
+ TableName: tableName,
120
+ Item: {
121
+ PK: `TENANT#${tenantId}`,
122
+ SK: 'ROTATION',
123
+ phase: 'started',
124
+ newPublicKey: request.newPublicKey,
125
+ newFingerprint,
126
+ oldFingerprint,
127
+ startedAt: now,
128
+ confirmations: [],
129
+ },
130
+ }),
131
+ );
132
+
133
+ // Store the new public key in tenant META so auth middleware accepts both keys
134
+ await ddb.send(
135
+ new UpdateCommand({
136
+ TableName: tableName,
137
+ Key: {
138
+ PK: `TENANT#${tenantId}`,
139
+ SK: 'META',
140
+ },
141
+ UpdateExpression: 'SET newPublicKey = :npk, rotationState = :state, updatedAt = :now',
142
+ ExpressionAttributeValues: {
143
+ ':npk': request.newPublicKey,
144
+ ':state': 'ROTATION_STARTED',
145
+ ':now': now,
146
+ },
147
+ }),
148
+ );
149
+
150
+ // Store the wrapped blob for the new key
151
+ await ddb.send(
152
+ new PutCommand({
153
+ TableName: tableName,
154
+ Item: {
155
+ PK: `TENANT#${tenantId}`,
156
+ SK: `WRAPPED_KEY#${newFingerprint}`,
157
+ data: request.wrappedBlob,
158
+ updatedAt: now,
159
+ },
160
+ }),
161
+ );
162
+
163
+ logger.info('Key rotation started', { tenantId, oldFingerprint, newFingerprint });
164
+
165
+ return {
166
+ statusCode: 200,
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({
169
+ status: 'rotation_started',
170
+ newFingerprint,
171
+ oldFingerprint,
172
+ }),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * POST /v1/rotate-confirm — Phase 2 of two-phase key rotation.
178
+ *
179
+ * Authenticated with the NEW key. Adds the device to the confirmations list.
180
+ * When all registered devices have confirmed (or the 48h timeout has elapsed),
181
+ * completes the rotation by removing the old key and rotation record.
182
+ */
183
+ export async function handleRotateConfirm(
184
+ tenantId: string,
185
+ fingerprint: string,
186
+ ddb: DynamoDBDocumentClient,
187
+ tableName: string,
188
+ ): Promise<HandlerResponse> {
189
+ // Get the rotation record
190
+ const rotationResult = await ddb.send(
191
+ new GetCommand({
192
+ TableName: tableName,
193
+ Key: {
194
+ PK: `TENANT#${tenantId}`,
195
+ SK: 'ROTATION',
196
+ },
197
+ }),
198
+ );
199
+
200
+ if (!rotationResult.Item) {
201
+ return {
202
+ statusCode: 404,
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify({ error: 'not_found', message: 'No key rotation in progress' }),
205
+ };
206
+ }
207
+
208
+ const rotation = rotationResult.Item;
209
+ const confirmations: string[] = rotation['confirmations'] as string[] ?? [];
210
+
211
+ // Add this device fingerprint to confirmations if not already present
212
+ if (!confirmations.includes(fingerprint)) {
213
+ confirmations.push(fingerprint);
214
+
215
+ await ddb.send(
216
+ new UpdateCommand({
217
+ TableName: tableName,
218
+ Key: {
219
+ PK: `TENANT#${tenantId}`,
220
+ SK: 'ROTATION',
221
+ },
222
+ UpdateExpression: 'SET confirmations = :c',
223
+ ExpressionAttributeValues: {
224
+ ':c': confirmations,
225
+ },
226
+ }),
227
+ );
228
+ }
229
+
230
+ // Check if rotation should be completed:
231
+ // Either all devices confirmed or 48h timeout elapsed
232
+ const startedAt = new Date(rotation['startedAt'] as string).getTime();
233
+ const timeoutReached = Date.now() - startedAt > ROTATION_TIMEOUT_MS;
234
+
235
+ // Count registered devices (WRAPPED_KEY# entries = registered devices)
236
+ const deviceResult = await ddb.send(
237
+ new QueryCommand({
238
+ TableName: tableName,
239
+ KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
240
+ ExpressionAttributeValues: {
241
+ ':pk': `TENANT#${tenantId}`,
242
+ ':prefix': 'WRAPPED_KEY#',
243
+ },
244
+ Select: 'COUNT',
245
+ }),
246
+ );
247
+
248
+ const deviceCount = deviceResult.Count ?? 0;
249
+ const allConfirmed = confirmations.length >= deviceCount;
250
+
251
+ if (allConfirmed || timeoutReached) {
252
+ // Complete the rotation: Phase 2
253
+ await completeRotation(tenantId, rotation, ddb, tableName);
254
+
255
+ logger.info('Key rotation completed', {
256
+ tenantId,
257
+ newFingerprint: rotation['newFingerprint'],
258
+ reason: timeoutReached ? 'timeout' : 'all_confirmed',
259
+ });
260
+
261
+ return {
262
+ statusCode: 200,
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({
265
+ status: 'rotation_complete',
266
+ confirmations: confirmations.length,
267
+ totalDevices: deviceCount,
268
+ }),
269
+ };
270
+ }
271
+
272
+ return {
273
+ statusCode: 200,
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: JSON.stringify({
276
+ status: 'confirmation_recorded',
277
+ confirmations: confirmations.length,
278
+ totalDevices: deviceCount,
279
+ }),
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Complete the rotation by:
285
+ * 1. Updating tenant META with the new public key as primary
286
+ * 2. Deleting old wrapped key blob
287
+ * 3. Deleting the rotation record
288
+ */
289
+ async function completeRotation(
290
+ tenantId: string,
291
+ rotation: Record<string, unknown>,
292
+ ddb: DynamoDBDocumentClient,
293
+ tableName: string,
294
+ ): Promise<void> {
295
+ const newPublicKey = rotation['newPublicKey'] as string;
296
+ const oldFingerprint = rotation['oldFingerprint'] as string;
297
+ const now = new Date().toISOString();
298
+
299
+ // Update tenant META: replace primary public key, clear rotation state
300
+ await ddb.send(
301
+ new UpdateCommand({
302
+ TableName: tableName,
303
+ Key: {
304
+ PK: `TENANT#${tenantId}`,
305
+ SK: 'META',
306
+ },
307
+ UpdateExpression: 'SET publicKey = :pk, updatedAt = :now REMOVE newPublicKey, rotationState',
308
+ ExpressionAttributeValues: {
309
+ ':pk': newPublicKey,
310
+ ':now': now,
311
+ },
312
+ }),
313
+ );
314
+
315
+ // Delete old wrapped key blob
316
+ await ddb.send(
317
+ new DeleteCommand({
318
+ TableName: tableName,
319
+ Key: {
320
+ PK: `TENANT#${tenantId}`,
321
+ SK: `WRAPPED_KEY#${oldFingerprint}`,
322
+ },
323
+ }),
324
+ );
325
+
326
+ // Delete rotation record
327
+ await ddb.send(
328
+ new DeleteCommand({
329
+ TableName: tableName,
330
+ Key: {
331
+ PK: `TENANT#${tenantId}`,
332
+ SK: 'ROTATION',
333
+ },
334
+ }),
335
+ );
336
+ }
@@ -0,0 +1,11 @@
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 handleCreateTenant(body: string | null | undefined, tenantId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
8
+ export declare function handleListTenants(tenantId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
9
+ export declare function handleDeleteTenant(projectTenantId: string, tenantId: string, ddb: DynamoDBDocumentClient, tableName: string): Promise<HandlerResponse>;
10
+ export {};
11
+ //# sourceMappingURL=tenants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenants.d.ts","sourceRoot":"","sources":["../../../../lib/handler/routes/tenants.ts"],"names":[],"mappings":"AACA,OAAO,EACL,sBAAsB,EAIvB,MAAM,uBAAuB,CAAC;AAG/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,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC/B,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAgE1B;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAuB1B;AAED,wBAAsB,kBAAkB,CACtC,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,sBAAsB,EAC3B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CA6E1B"}