@learncard/lca-api-service 1.6.2 → 1.7.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/.env.example +2 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +132 -0
- package/config/config.dev.json +1 -0
- package/config/config.example.json +1 -0
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/dist/accesslayer/notifications/create.d.ts +2 -1
- package/dist/app.d.ts +70 -8
- package/dist/config/environment.d.ts +6 -0
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/helpers/analyticsEligibility.d.ts +6 -0
- package/dist/helpers/brainUrl.d.ts +1 -0
- package/dist/helpers/brainUrl.test.d.ts +1 -0
- package/dist/helpers/currentProfile.d.ts +6 -0
- package/dist/helpers/currentProfile.test.d.ts +1 -0
- package/dist/helpers/retryingOnce.d.ts +1 -0
- package/dist/helpers/retryingOnce.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +1 -0
- package/dist/models/EscrowHold.indexes.test.d.ts +1 -0
- package/dist/models/Notification.d.ts +114 -2
- package/dist/models/SigningAuthority.d.ts +1 -1
- package/dist/models/UserKey.d.ts +23 -0
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/index.d.ts +5 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +65 -4
- package/dist/routes/preferences.test.d.ts +1 -0
- package/dist/routes/signing-authority.d.ts +3 -3
- package/dist/services/escrow-enclave/index.d.ts +2 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/types/credentials.d.ts +1 -1
- package/dist/types/notifications.d.ts +118 -2
- package/docs/bedrock-generation.md +7 -6
- package/lambdaApp.test.ts +114 -0
- package/lambdaApp.ts +23 -11
- package/package.json +11 -11
- package/scripts/escrow-lambda-http.ts +74 -0
- package/scripts/verify-escrow-startup.mjs +463 -0
- package/serverless-local.yml +1 -1
- package/serverless.yml +1 -1
- package/src/accesslayer/notifications/create.test.ts +80 -0
- package/src/accesslayer/notifications/create.ts +97 -2
- package/src/config/environment.test.ts +27 -1
- package/src/config/environment.ts +16 -0
- package/src/config/escrowDeployment.test.ts +1 -0
- package/src/docker-entry.ts +1 -2
- package/src/helpers/analyticsEligibility.ts +15 -0
- package/src/helpers/brainUrl.test.ts +13 -0
- package/src/helpers/brainUrl.ts +3 -0
- package/src/helpers/currentProfile.test.ts +55 -0
- package/src/helpers/currentProfile.ts +58 -0
- package/src/helpers/locale.helpers.test.ts +4 -1
- package/src/helpers/locale.helpers.ts +4 -2
- package/src/helpers/retryingOnce.test.ts +33 -0
- package/src/helpers/retryingOnce.ts +18 -0
- package/src/models/EscrowHold.indexes.test.ts +208 -0
- package/src/models/EscrowHold.ts +106 -51
- package/src/models/UserKey.ts +69 -1
- package/src/routes/escrow.ts +30 -12
- package/src/routes/index.ts +5 -8
- package/src/routes/keys.ts +8 -0
- package/src/routes/notifications.ts +34 -1
- package/src/routes/preferences.test.ts +216 -0
- package/src/routes/preferences.ts +54 -4
- package/src/services/escrow-enclave/index.ts +38 -1
- package/src/services/escrow-enclave/logFailure.ts +5 -1
- package/src/services/escrow-enclave/production.test.ts +64 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +14 -2
- package/test/jobs/escrowHoldReminders.spec.ts +21 -4
- package/test/keys-escrow.spec.ts +149 -3
- package/test/keys-lifecycle-invariants.spec.ts +235 -0
- package/test/keys-pin-sign-in.spec.ts +34 -2
- package/test/notifications.spec.ts +102 -0
- package/tsconfig.entrypoints.json +1 -1
- package/vitest.config.ts +1 -0
- package/scripts/node24-runtime.cjs +0 -9
- package/scripts/verify-node24-layer.cjs +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/lca-api-service",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"start-p-5000": "PORT=5000 serverless offline --httpPort 5000 --websocketPort 5001 --lambdaPort 5002 --albPort 5003 --config serverless-local.yml",
|
|
29
29
|
"dev": "bun --conditions=development --watch src/docker-entry.ts",
|
|
30
30
|
"start:docker": "bun --conditions=development src/docker-entry.ts",
|
|
31
|
-
"serverless-deploy": "node scripts/check-seed-encryption-infrastructure.mjs && node ../../../scripts/prepare-didkit-native-layer.cjs && node scripts/verify-node24-layer.cjs && serverless deploy",
|
|
31
|
+
"serverless-deploy": "node scripts/check-seed-encryption-infrastructure.mjs && node ../../../scripts/prepare-didkit-native-layer.cjs && node ../../../scripts/verify-node24-didkit-layer.cjs && serverless deploy",
|
|
32
32
|
"serverless-deploy-scouts": "serverless deploy --config serverless-scouts.yml",
|
|
33
33
|
"test:oidc:e2e": "node test/oidc-e2e/run.mjs",
|
|
34
34
|
"typecheck:oidc:e2e": "tsc --noEmit -p test/oidc-e2e/tsconfig.json",
|
|
@@ -66,15 +66,15 @@
|
|
|
66
66
|
"@fastify/formbody": "^8.0.2",
|
|
67
67
|
"@fastify/rate-limit": "^10.2.0",
|
|
68
68
|
"@fastify/static": "^10.1.1",
|
|
69
|
-
"@learncard/core": "9.4.
|
|
70
|
-
"@learncard/did-web-plugin": "1.1.
|
|
71
|
-
"@learncard/didkit-plugin": "1.10.
|
|
72
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
73
|
-
"@learncard/email-templates": "1.
|
|
74
|
-
"@learncard/helpers": "1.
|
|
75
|
-
"@learncard/init": "2.5.
|
|
76
|
-
"@learncard/types": "
|
|
77
|
-
"@learncard/sss-key-manager": "0.
|
|
69
|
+
"@learncard/core": "9.4.44",
|
|
70
|
+
"@learncard/did-web-plugin": "1.1.44",
|
|
71
|
+
"@learncard/didkit-plugin": "1.10.10",
|
|
72
|
+
"@learncard/didkit-plugin-node": "0.3.10",
|
|
73
|
+
"@learncard/email-templates": "1.3.0",
|
|
74
|
+
"@learncard/helpers": "1.8.0",
|
|
75
|
+
"@learncard/init": "2.5.10",
|
|
76
|
+
"@learncard/types": "6.0.0",
|
|
77
|
+
"@learncard/sss-key-manager": "0.3.0",
|
|
78
78
|
"@sentry/serverless": "^7.61.0",
|
|
79
79
|
"@trpc/server": "11.8.0",
|
|
80
80
|
"@types/jsonwebtoken": "^9.0.8",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Local API Gateway bridge to the real Lambda entrypoint; no application mocks. */
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';
|
|
4
|
+
import { openApiHandler } from '../lambda';
|
|
5
|
+
|
|
6
|
+
const server = createServer(async (request, response) => {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
9
|
+
const chunks: Buffer[] = [];
|
|
10
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
11
|
+
const headers = Object.fromEntries(
|
|
12
|
+
Object.entries(request.headers).flatMap(([key, value]) =>
|
|
13
|
+
value === undefined ? [] : [[key, Array.isArray(value) ? value.join(',') : value]]
|
|
14
|
+
)
|
|
15
|
+
);
|
|
16
|
+
const event: APIGatewayProxyEventV2 = {
|
|
17
|
+
version: '2.0',
|
|
18
|
+
routeKey: '$default',
|
|
19
|
+
rawPath: url.pathname,
|
|
20
|
+
rawQueryString: url.search.slice(1),
|
|
21
|
+
pathParameters: { trpc: url.pathname.replace(/^\/api\//, '') },
|
|
22
|
+
headers,
|
|
23
|
+
isBase64Encoded: false,
|
|
24
|
+
body: Buffer.concat(chunks).toString() || undefined,
|
|
25
|
+
requestContext: {
|
|
26
|
+
accountId: 'local',
|
|
27
|
+
apiId: 'owned-verification',
|
|
28
|
+
domainName: 'localhost',
|
|
29
|
+
domainPrefix: 'localhost',
|
|
30
|
+
requestId: crypto.randomUUID(),
|
|
31
|
+
routeKey: '$default',
|
|
32
|
+
stage: '$default',
|
|
33
|
+
time: new Date().toISOString(),
|
|
34
|
+
timeEpoch: Date.now(),
|
|
35
|
+
http: {
|
|
36
|
+
method: request.method ?? 'GET',
|
|
37
|
+
path: url.pathname,
|
|
38
|
+
protocol: 'HTTP/1.1',
|
|
39
|
+
sourceIp: '127.0.0.1',
|
|
40
|
+
userAgent: 'owned-verification',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const context: Context = {
|
|
45
|
+
callbackWaitsForEmptyEventLoop: false,
|
|
46
|
+
functionName: 'owned-verification',
|
|
47
|
+
functionVersion: '$LATEST',
|
|
48
|
+
invokedFunctionArn: 'local',
|
|
49
|
+
memoryLimitInMB: '1024',
|
|
50
|
+
awsRequestId: event.requestContext.requestId,
|
|
51
|
+
logGroupName: 'local',
|
|
52
|
+
logStreamName: 'local',
|
|
53
|
+
getRemainingTimeInMillis: () => 120_000,
|
|
54
|
+
done: () => {},
|
|
55
|
+
fail: () => {},
|
|
56
|
+
succeed: () => {},
|
|
57
|
+
};
|
|
58
|
+
const result = await openApiHandler(event, context, () => {});
|
|
59
|
+
if (typeof result !== 'object' || !result) throw new Error('Expected Lambda HTTP response');
|
|
60
|
+
response.writeHead(
|
|
61
|
+
result.statusCode ?? 200,
|
|
62
|
+
Object.fromEntries(
|
|
63
|
+
Object.entries(result.headers ?? {}).map(([key, value]) => [key, String(value)])
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
response.end(
|
|
67
|
+
result.isBase64Encoded ? Buffer.from(result.body ?? '', 'base64') : result.body
|
|
68
|
+
);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('Local Lambda bridge failed', error);
|
|
71
|
+
response.writeHead(500).end('Local Lambda bridge failed');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
server.listen(Number(process.env.PORT), '127.0.0.1');
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owned-only real Mongo/Redis + actual Docker/Lambda-entrypoint proof.
|
|
3
|
+
* First run `lc verify up`; pass its immutable imageId. No external DB/URI accepted.
|
|
4
|
+
* Usage: LC_VERIFY_TRUST_LOCAL_DOCKER=1 bun --conditions=development \
|
|
5
|
+
* scripts/verify-escrow-startup.mjs --image sha256:... --evidence /private/new-directory
|
|
6
|
+
* Requires cached mongo:7 and redis:7 images. Never adopts/removes existing resources.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
10
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
11
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { createServer } from 'node:net';
|
|
14
|
+
import { dirname, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
17
|
+
import { MongoClient } from 'mongodb';
|
|
18
|
+
import { initLearnCard } from '@learncard/init';
|
|
19
|
+
import {
|
|
20
|
+
encryptEscrowBlob,
|
|
21
|
+
generateEscrowKeyPair,
|
|
22
|
+
openEscrowRelease,
|
|
23
|
+
splitPrivateKey,
|
|
24
|
+
} from '@learncard/sss-key-manager';
|
|
25
|
+
|
|
26
|
+
assert.equal(
|
|
27
|
+
process.env.LC_VERIFY_TRUST_LOCAL_DOCKER,
|
|
28
|
+
'1',
|
|
29
|
+
'Run trusted-local Docker preflight first'
|
|
30
|
+
);
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
const image = args[args.indexOf('--image') + 1];
|
|
33
|
+
const evidence = args[args.indexOf('--evidence') + 1];
|
|
34
|
+
assert.match(image ?? '', /^sha256:[a-f0-9]{64}$/);
|
|
35
|
+
assert.ok(evidence?.startsWith('/'), 'Use a NEW absolute private evidence directory');
|
|
36
|
+
await mkdir(evidence, { mode: 0o700 }); // Fails rather than reusing another run.
|
|
37
|
+
const service = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const token = randomBytes(12).toString('hex');
|
|
39
|
+
const label = 'org.learncard.escrow-startup-verification';
|
|
40
|
+
const network = `escrow-proof-${token}`;
|
|
41
|
+
const owned = [];
|
|
42
|
+
const cleanEnv = Object.fromEntries(
|
|
43
|
+
['PATH', 'HOME', 'TMPDIR'].flatMap(key => (process.env[key] ? [[key, process.env[key]]] : []))
|
|
44
|
+
);
|
|
45
|
+
const docker = (...argv) =>
|
|
46
|
+
execFileSync('docker', ['--host', 'unix:///var/run/docker.sock', ...argv], {
|
|
47
|
+
env: cleanEnv,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
}).trim();
|
|
51
|
+
const containerLogs = id => {
|
|
52
|
+
const result = spawnSync('docker', ['--host', 'unix:///var/run/docker.sock', 'logs', id], {
|
|
53
|
+
env: cleanEnv,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
assert.equal(result.status, 0);
|
|
58
|
+
return result.stdout + result.stderr;
|
|
59
|
+
};
|
|
60
|
+
const port = () =>
|
|
61
|
+
new Promise((done, reject) => {
|
|
62
|
+
const socket = createServer();
|
|
63
|
+
socket.once('error', reject);
|
|
64
|
+
socket.listen(0, '127.0.0.1', () => {
|
|
65
|
+
const address = socket.address();
|
|
66
|
+
assert.ok(address && typeof address === 'object');
|
|
67
|
+
socket.close(() => done(address.port));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
const container = (...argv) => {
|
|
71
|
+
const id = docker('run', '-d', '--pull=never', '--label', `${label}=${token}`, ...argv);
|
|
72
|
+
owned.push(id);
|
|
73
|
+
return id;
|
|
74
|
+
};
|
|
75
|
+
const remove = id => {
|
|
76
|
+
assert.equal(docker('inspect', '--format', `{{index .Config.Labels "${label}"}}`, id), token);
|
|
77
|
+
docker('rm', '-f', '-v', id);
|
|
78
|
+
owned.splice(owned.indexOf(id), 1);
|
|
79
|
+
};
|
|
80
|
+
const keyId = 'owned-proof';
|
|
81
|
+
const enclave = await generateEscrowKeyPair();
|
|
82
|
+
const recipient = await generateEscrowKeyPair();
|
|
83
|
+
const shares = await splitPrivateKey('b'.repeat(64));
|
|
84
|
+
const require = createRequire(import.meta.url);
|
|
85
|
+
const card = await initLearnCard({
|
|
86
|
+
seed: 'b'.repeat(64),
|
|
87
|
+
didkit: readFile(require.resolve('@learncard/didkit-plugin/dist/didkit_wasm_bg.wasm')),
|
|
88
|
+
});
|
|
89
|
+
const did = card.id.did();
|
|
90
|
+
const envelope = await encryptEscrowBlob(
|
|
91
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
|
|
92
|
+
enclave.publicKey,
|
|
93
|
+
keyId
|
|
94
|
+
);
|
|
95
|
+
const results = [];
|
|
96
|
+
let networkCreated = false;
|
|
97
|
+
let mongo;
|
|
98
|
+
let child;
|
|
99
|
+
let activeContainer;
|
|
100
|
+
let logs = '';
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
docker('image', 'inspect', image);
|
|
104
|
+
docker('network', 'create', '--label', `${label}=${token}`, network);
|
|
105
|
+
networkCreated = true;
|
|
106
|
+
const mongoPort = await port();
|
|
107
|
+
const redisPort = await port();
|
|
108
|
+
container(
|
|
109
|
+
'--network',
|
|
110
|
+
network,
|
|
111
|
+
'--network-alias',
|
|
112
|
+
'mongo',
|
|
113
|
+
'-p',
|
|
114
|
+
`127.0.0.1:${mongoPort}:27017`,
|
|
115
|
+
'mongo:7',
|
|
116
|
+
'--bind_ip_all'
|
|
117
|
+
);
|
|
118
|
+
const redisId = container(
|
|
119
|
+
'--network',
|
|
120
|
+
network,
|
|
121
|
+
'--network-alias',
|
|
122
|
+
'redis',
|
|
123
|
+
'-p',
|
|
124
|
+
`127.0.0.1:${redisPort}:6379`,
|
|
125
|
+
'redis:7'
|
|
126
|
+
);
|
|
127
|
+
mongo = new MongoClient(`mongodb://127.0.0.1:${mongoPort}/?directConnection=true`, {
|
|
128
|
+
serverSelectionTimeoutMS: 30_000,
|
|
129
|
+
});
|
|
130
|
+
await mongo.connect();
|
|
131
|
+
const redisDeadline = Date.now() + 30_000;
|
|
132
|
+
while (docker('exec', redisId, 'redis-cli', 'ping') !== 'PONG') {
|
|
133
|
+
assert.ok(Date.now() < redisDeadline, 'Owned Redis failed to start');
|
|
134
|
+
await delay(100);
|
|
135
|
+
}
|
|
136
|
+
for (const runtime of ['docker', 'lambda']) {
|
|
137
|
+
for (const fixture of ['duplicates', 'conflict', 'clean']) {
|
|
138
|
+
const database = `owned_${runtime}_${fixture}_${token}`;
|
|
139
|
+
const db = mongo.db(database);
|
|
140
|
+
const holds = db.collection('escrowholds');
|
|
141
|
+
const resumeToken = randomBytes(32).toString('hex');
|
|
142
|
+
const fixtureId = randomUUID();
|
|
143
|
+
const now = new Date();
|
|
144
|
+
const hold = {
|
|
145
|
+
_id: fixtureId,
|
|
146
|
+
authProvider: { type: 'firebase', id: 'completion-fixture' },
|
|
147
|
+
primaryDid: did,
|
|
148
|
+
shareVersion: 1,
|
|
149
|
+
status: 'pending',
|
|
150
|
+
identityProofType: 'auth-token',
|
|
151
|
+
requestedAt: now,
|
|
152
|
+
releaseAfter: now,
|
|
153
|
+
releasePolicy: 'hold',
|
|
154
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
155
|
+
resumeTokenHash: createHash('sha256').update(resumeToken).digest('hex'),
|
|
156
|
+
notifications: [],
|
|
157
|
+
createdAt: now,
|
|
158
|
+
updatedAt: now,
|
|
159
|
+
holdRecord: {
|
|
160
|
+
hold: {
|
|
161
|
+
holdId: fixtureId,
|
|
162
|
+
did,
|
|
163
|
+
shareVersion: 1,
|
|
164
|
+
blobHash: 'a'.repeat(64),
|
|
165
|
+
enrollmentEpoch: 1,
|
|
166
|
+
releasePolicy: 'hold',
|
|
167
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
168
|
+
createdLo: now.getTime(),
|
|
169
|
+
createdHi: now.getTime(),
|
|
170
|
+
policyVersion: 1,
|
|
171
|
+
signature: 'synthetic',
|
|
172
|
+
},
|
|
173
|
+
holdDurationMs: 1,
|
|
174
|
+
ledgerSeq: 0,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
// Seed BEFORE starting either API. Fixture capability cannot release a share.
|
|
178
|
+
if (fixture !== 'clean') await holds.insertOne(hold);
|
|
179
|
+
if (fixture === 'duplicates') {
|
|
180
|
+
await holds.insertMany(
|
|
181
|
+
[1, 2].map(() => ({
|
|
182
|
+
_id: randomUUID(),
|
|
183
|
+
authProvider: { type: 'firebase', id: 'legacy-duplicate' },
|
|
184
|
+
status: 'pending',
|
|
185
|
+
}))
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (fixture === 'conflict') {
|
|
189
|
+
await holds.createIndex(
|
|
190
|
+
{ 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
|
|
191
|
+
{ name: 'pending_escrow_identity_policy_unique' }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const apiPort = await port();
|
|
195
|
+
const base = `http://127.0.0.1:${apiPort}/api`;
|
|
196
|
+
const localHome = resolve(evidence, 'empty-home');
|
|
197
|
+
await mkdir(localHome, { recursive: true, mode: 0o700 });
|
|
198
|
+
const environment = {
|
|
199
|
+
NODE_ENV: 'development',
|
|
200
|
+
METABASE_SECRET_KEY: 'd'.repeat(64),
|
|
201
|
+
IS_OFFLINE: 'true',
|
|
202
|
+
IS_E2E_TEST: 'true',
|
|
203
|
+
SEED: 'a'.repeat(64),
|
|
204
|
+
PORT: String(apiPort),
|
|
205
|
+
DOMAIN_NAME: `localhost%3A${apiPort}`,
|
|
206
|
+
MONGO_URI:
|
|
207
|
+
runtime === 'docker'
|
|
208
|
+
? 'mongodb://mongo:27017/?directConnection=true'
|
|
209
|
+
: `mongodb://127.0.0.1:${mongoPort}/?directConnection=true`,
|
|
210
|
+
MONGO_DB_NAME: database,
|
|
211
|
+
REDIS_HOST: runtime === 'docker' ? 'redis' : '127.0.0.1',
|
|
212
|
+
REDIS_PORT: runtime === 'docker' ? '6379' : String(redisPort),
|
|
213
|
+
SA_SEED_ENCRYPT_WRITES: 'false',
|
|
214
|
+
SA_SEED_ALLOW_LEGACY_READ: 'true',
|
|
215
|
+
SKIP_DIDKIT_NAPI: '1',
|
|
216
|
+
ESCROW_ENCLAVE_MODE: 'software',
|
|
217
|
+
ESCROW_ENCLAVE_ACTIVE_KEY_ID: keyId,
|
|
218
|
+
ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: JSON.stringify({
|
|
219
|
+
[keyId]: enclave.privateKey,
|
|
220
|
+
}),
|
|
221
|
+
ESCROW_HOLD_DURATION_MS: '1',
|
|
222
|
+
SENTRY_DSN: '',
|
|
223
|
+
AWS_EC2_METADATA_DISABLED: 'true',
|
|
224
|
+
AWS_ACCESS_KEY_ID: 'notARealKey',
|
|
225
|
+
AWS_SECRET_ACCESS_KEY: 'notARealSecretKey',
|
|
226
|
+
};
|
|
227
|
+
logs = '';
|
|
228
|
+
if (runtime === 'docker') {
|
|
229
|
+
activeContainer = container(
|
|
230
|
+
'--network',
|
|
231
|
+
network,
|
|
232
|
+
'-p',
|
|
233
|
+
`127.0.0.1:${apiPort}:${apiPort}`,
|
|
234
|
+
...Object.entries(environment).flatMap(([key, value]) => [
|
|
235
|
+
'-e',
|
|
236
|
+
`${key}=${value}`,
|
|
237
|
+
]),
|
|
238
|
+
'-w',
|
|
239
|
+
'/app/services/learn-card-network/lca-api',
|
|
240
|
+
image,
|
|
241
|
+
'bun',
|
|
242
|
+
'run',
|
|
243
|
+
'start:docker'
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
child = spawn(
|
|
247
|
+
'bun',
|
|
248
|
+
['--conditions=development', 'scripts/escrow-lambda-http.ts'],
|
|
249
|
+
{
|
|
250
|
+
cwd: service,
|
|
251
|
+
env: { PATH: process.env.PATH, HOME: localHome, ...environment },
|
|
252
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
child.stdout.on('data', data => {
|
|
256
|
+
logs += data;
|
|
257
|
+
});
|
|
258
|
+
child.stderr.on('data', data => {
|
|
259
|
+
logs += data;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const request = async (method, path, body, authorization) => {
|
|
263
|
+
const response = await fetch(base + path, {
|
|
264
|
+
method,
|
|
265
|
+
headers: {
|
|
266
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
267
|
+
...(authorization ? { Authorization: `Bearer ${authorization}` } : {}),
|
|
268
|
+
},
|
|
269
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
270
|
+
signal: AbortSignal.timeout(20_000),
|
|
271
|
+
});
|
|
272
|
+
return { status: response.status, body: await response.json() };
|
|
273
|
+
};
|
|
274
|
+
const ok = result => {
|
|
275
|
+
assert.equal(result.status, 200, JSON.stringify(result));
|
|
276
|
+
return result.body;
|
|
277
|
+
};
|
|
278
|
+
const deadline = Date.now() + 120_000;
|
|
279
|
+
while (true) {
|
|
280
|
+
try {
|
|
281
|
+
if ((await request('GET', '/health-check')).status === 200) break;
|
|
282
|
+
} catch {
|
|
283
|
+
/* wait for owned startup */
|
|
284
|
+
}
|
|
285
|
+
assert.ok(Date.now() < deadline, `${runtime}/${fixture} API startup timed out`);
|
|
286
|
+
assert.ok(!child || child.exitCode === null, 'Owned Lambda bridge exited');
|
|
287
|
+
if (activeContainer)
|
|
288
|
+
assert.equal(
|
|
289
|
+
docker('inspect', '--format', '{{.State.Running}}', activeContainer),
|
|
290
|
+
'true',
|
|
291
|
+
'Owned Docker entrypoint exited'
|
|
292
|
+
);
|
|
293
|
+
await delay(200);
|
|
294
|
+
}
|
|
295
|
+
const normal = () =>
|
|
296
|
+
card.invoke
|
|
297
|
+
.getDidAuthVp({ proofFormat: 'jwt' })
|
|
298
|
+
.then(jwt => request('GET', '/did', undefined, jwt))
|
|
299
|
+
.then(ok);
|
|
300
|
+
await normal();
|
|
301
|
+
const owner = async (method, path, body) => {
|
|
302
|
+
const jwt = await card.invoke.getDidAuthVp({ proofFormat: 'jwt' });
|
|
303
|
+
const challenge = ok(await request('POST', '/keys/challenge', { did }, jwt));
|
|
304
|
+
return request(
|
|
305
|
+
method,
|
|
306
|
+
path,
|
|
307
|
+
body,
|
|
308
|
+
await card.invoke.getDidAuthVp({
|
|
309
|
+
proofFormat: 'jwt',
|
|
310
|
+
challenge: challenge.challenge,
|
|
311
|
+
})
|
|
312
|
+
);
|
|
313
|
+
};
|
|
314
|
+
const identity = `owned-${runtime}-${fixture}`;
|
|
315
|
+
const auth = {
|
|
316
|
+
providerType: 'firebase',
|
|
317
|
+
authToken: `header.${Buffer.from(JSON.stringify({ sub: identity, email: `${identity}@example.invalid` })).toString('base64url')}.signature`,
|
|
318
|
+
};
|
|
319
|
+
ok(
|
|
320
|
+
await owner('PUT', '/keys/auth-share', {
|
|
321
|
+
...auth,
|
|
322
|
+
primaryDid: did,
|
|
323
|
+
authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
324
|
+
})
|
|
325
|
+
);
|
|
326
|
+
ok(
|
|
327
|
+
await owner('POST', '/keys/escrow', {
|
|
328
|
+
...auth,
|
|
329
|
+
envelope,
|
|
330
|
+
shareVersion: 1,
|
|
331
|
+
enclaveKeyId: keyId,
|
|
332
|
+
})
|
|
333
|
+
);
|
|
334
|
+
const beforeCount = await holds.countDocuments();
|
|
335
|
+
const start = await request('POST', '/keys/escrow/recover', {
|
|
336
|
+
...auth,
|
|
337
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
338
|
+
});
|
|
339
|
+
let diagnostic;
|
|
340
|
+
if (fixture !== 'clean') {
|
|
341
|
+
assert.equal(start.status, 500, 'Escrow start must fail closed');
|
|
342
|
+
assert.equal(
|
|
343
|
+
await holds.countDocuments(),
|
|
344
|
+
beforeCount,
|
|
345
|
+
'No new hold may be inserted'
|
|
346
|
+
);
|
|
347
|
+
const complete = await request('POST', '/keys/escrow/complete', {
|
|
348
|
+
holdId: fixtureId,
|
|
349
|
+
resumeToken,
|
|
350
|
+
});
|
|
351
|
+
assert.equal(
|
|
352
|
+
complete.status,
|
|
353
|
+
500,
|
|
354
|
+
'Authenticated escrow completion must fail closed'
|
|
355
|
+
);
|
|
356
|
+
assert.deepEqual(
|
|
357
|
+
await holds.findOne({ _id: fixtureId }),
|
|
358
|
+
hold,
|
|
359
|
+
'No completion claim/charge/release may be persisted'
|
|
360
|
+
);
|
|
361
|
+
if (activeContainer) logs = containerLogs(activeContainer);
|
|
362
|
+
const code = fixture === 'duplicates' ? 11000 : 86;
|
|
363
|
+
const codeName =
|
|
364
|
+
fixture === 'duplicates' ? 'DuplicateKey' : 'IndexKeySpecsConflict';
|
|
365
|
+
assert.match(logs, new RegExp(`code: ${code}\\b`));
|
|
366
|
+
assert.match(logs, new RegExp(`codeName: ['"]${codeName}['"]`));
|
|
367
|
+
assert.match(logs, /errmsg: ['"].+/);
|
|
368
|
+
if (fixture === 'duplicates') assert.doesNotMatch(logs, /legacy-duplicate/);
|
|
369
|
+
diagnostic = {
|
|
370
|
+
code,
|
|
371
|
+
codeName,
|
|
372
|
+
errmsg: 'present',
|
|
373
|
+
...(fixture === 'duplicates' ? { duplicateKeyRedacted: true } : {}),
|
|
374
|
+
};
|
|
375
|
+
} else {
|
|
376
|
+
const started = ok(start);
|
|
377
|
+
assert.ok(started.resumeToken);
|
|
378
|
+
const indexes = await holds.listIndexes().toArray();
|
|
379
|
+
const unique = indexes.find(
|
|
380
|
+
index => index.name === 'pending_escrow_identity_policy_unique'
|
|
381
|
+
);
|
|
382
|
+
assert.equal(unique?.unique, true);
|
|
383
|
+
assert.deepEqual(unique.partialFilterExpression, { status: 'pending' });
|
|
384
|
+
assert.ok(!indexes.some(index => index.name === 'pending_escrow_identity_unique'));
|
|
385
|
+
const wait = Math.max(
|
|
386
|
+
0,
|
|
387
|
+
new Date(started.releaseAfter).getTime() - Date.now() + 100
|
|
388
|
+
);
|
|
389
|
+
assert.ok(wait <= 70_000, 'Expected synthetic one-ms hold plus real safety margin');
|
|
390
|
+
await delay(wait); // Real clock, no document or fake-time shortcut.
|
|
391
|
+
const completed = ok(
|
|
392
|
+
await request('POST', '/keys/escrow/complete', {
|
|
393
|
+
holdId: started.holdId,
|
|
394
|
+
resumeToken: started.resumeToken,
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
const opened = await openEscrowRelease(completed.sealedShare, recipient.privateKey);
|
|
398
|
+
assert.equal(opened.recoveryShare, shares.recoveryShare);
|
|
399
|
+
assert.equal(opened.did, did);
|
|
400
|
+
assert.equal((await holds.findOne({ _id: started.holdId })).status, 'completed');
|
|
401
|
+
if (activeContainer) logs = containerLogs(activeContainer);
|
|
402
|
+
assert.doesNotMatch(logs, /\[escrow-hold-indexes\] initialization failed/);
|
|
403
|
+
diagnostic = {
|
|
404
|
+
indexes: indexes.map(index => index.name),
|
|
405
|
+
sealedShareDecrypted: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
ok(await request('GET', '/health-check'));
|
|
409
|
+
await normal();
|
|
410
|
+
results.push({
|
|
411
|
+
runtime,
|
|
412
|
+
fixture,
|
|
413
|
+
health: 200,
|
|
414
|
+
authenticatedDid: 200,
|
|
415
|
+
escrow:
|
|
416
|
+
fixture === 'clean'
|
|
417
|
+
? 'created/completed/decrypted'
|
|
418
|
+
: 'start/complete 500; no hold inserted or completion mutation',
|
|
419
|
+
diagnostic,
|
|
420
|
+
});
|
|
421
|
+
console.log(JSON.stringify(results.at(-1)));
|
|
422
|
+
await writeFile(resolve(evidence, `${runtime}-${fixture}.log`), logs, { mode: 0o600 });
|
|
423
|
+
if (child) {
|
|
424
|
+
child.kill('SIGTERM');
|
|
425
|
+
await new Promise(done => child.once('exit', done));
|
|
426
|
+
child = undefined;
|
|
427
|
+
}
|
|
428
|
+
if (activeContainer) {
|
|
429
|
+
remove(activeContainer);
|
|
430
|
+
activeContainer = undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
await writeFile(
|
|
435
|
+
resolve(evidence, 'summary.json'),
|
|
436
|
+
JSON.stringify({ image, results }, null, 2),
|
|
437
|
+
{ mode: 0o600 }
|
|
438
|
+
);
|
|
439
|
+
} finally {
|
|
440
|
+
if (child) {
|
|
441
|
+
await writeFile(resolve(evidence, 'failure-lambda.log'), logs, { mode: 0o600 });
|
|
442
|
+
if (child.exitCode === null) child.kill('SIGTERM');
|
|
443
|
+
}
|
|
444
|
+
if (activeContainer) {
|
|
445
|
+
await writeFile(resolve(evidence, 'failure-docker.log'), containerLogs(activeContainer), {
|
|
446
|
+
mode: 0o600,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
await mongo?.close();
|
|
450
|
+
for (const id of [...owned].reverse()) {
|
|
451
|
+
await writeFile(resolve(evidence, `container-${id.slice(0, 12)}.log`), containerLogs(id), {
|
|
452
|
+
mode: 0o600,
|
|
453
|
+
});
|
|
454
|
+
remove(id);
|
|
455
|
+
}
|
|
456
|
+
if (networkCreated) {
|
|
457
|
+
assert.equal(
|
|
458
|
+
docker('network', 'inspect', '--format', `{{index .Labels "${label}"}}`, network),
|
|
459
|
+
token
|
|
460
|
+
);
|
|
461
|
+
docker('network', 'rm', network);
|
|
462
|
+
}
|
|
463
|
+
}
|
package/serverless-local.yml
CHANGED