@learncard/lca-api-service 1.3.2 → 1.5.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 +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +13 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'crypto';
|
|
2
|
+
import { escrowBlobIdentity } from './blobIdentity';
|
|
3
|
+
import type { EnclaveCreateHoldInput, EscrowHoldRecord, CancelHoldRequest } from './types';
|
|
4
|
+
import {
|
|
5
|
+
decryptEscrowBlob,
|
|
6
|
+
encryptEscrowBlob,
|
|
7
|
+
sealEscrowRelease,
|
|
8
|
+
parseEscrowEnvelope,
|
|
9
|
+
type EscrowBlobPlaintext,
|
|
10
|
+
} from '@learncard/sss-key-manager';
|
|
11
|
+
import {
|
|
12
|
+
EscrowBlobError,
|
|
13
|
+
EscrowPolicyError,
|
|
14
|
+
EscrowPinMismatchError,
|
|
15
|
+
EscrowUnavailableError,
|
|
16
|
+
type EscrowEnclave,
|
|
17
|
+
type EnclaveAttestation,
|
|
18
|
+
type VerifyEscrowBlobInput,
|
|
19
|
+
type VerifyEscrowBlobResult,
|
|
20
|
+
type ReleaseRequest,
|
|
21
|
+
type ReleaseResult,
|
|
22
|
+
type CarryPinVerifierInput,
|
|
23
|
+
type RewrapEscrowBlobInput,
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
export interface SoftwareEnclaveConfig {
|
|
27
|
+
privateKeys: Record<string, string>;
|
|
28
|
+
activeKeyId: string;
|
|
29
|
+
/** Supplied by the factory to match its configured host-trusted waiting period. */
|
|
30
|
+
holdDurationMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** In-process backend for development; host time is not an attested clock. */
|
|
34
|
+
export class SoftwareEnclave implements EscrowEnclave {
|
|
35
|
+
private readonly privateKeys: ReadonlyMap<string, string>;
|
|
36
|
+
private readonly activeKeyId: string;
|
|
37
|
+
private readonly holdDurationMs: number;
|
|
38
|
+
constructor(config: SoftwareEnclaveConfig) {
|
|
39
|
+
this.privateKeys = new Map(Object.entries(config.privateKeys));
|
|
40
|
+
this.activeKeyId = config.activeKeyId;
|
|
41
|
+
if (!this.privateKeys.has(this.activeKeyId)) throw new EscrowUnavailableError();
|
|
42
|
+
// Match the factory configuration without a circular import; default is seven days.
|
|
43
|
+
this.holdDurationMs = config.holdDurationMs ?? 7 * 24 * 60 * 60 * 1000;
|
|
44
|
+
}
|
|
45
|
+
async createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }> {
|
|
46
|
+
const blob = await this.decrypt(input.envelope);
|
|
47
|
+
if (
|
|
48
|
+
blob.did !== input.expectedDid ||
|
|
49
|
+
blob.shareVersion !== input.expectedShareVersion ||
|
|
50
|
+
!Number.isInteger(input.enrollmentEpoch) ||
|
|
51
|
+
input.enrollmentEpoch <= 0 ||
|
|
52
|
+
(input.releasePolicy === 'pin' && !blob.pinVerifier)
|
|
53
|
+
)
|
|
54
|
+
throw new EscrowPolicyError();
|
|
55
|
+
const blobHash = escrowBlobIdentity(input.envelope);
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
return {
|
|
58
|
+
holdRecord: {
|
|
59
|
+
hold: {
|
|
60
|
+
holdId: input.holdId,
|
|
61
|
+
did: blob.did,
|
|
62
|
+
shareVersion: input.expectedShareVersion,
|
|
63
|
+
blobHash,
|
|
64
|
+
enrollmentEpoch: input.enrollmentEpoch,
|
|
65
|
+
releasePolicy: input.releasePolicy,
|
|
66
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
67
|
+
createdLo: now,
|
|
68
|
+
createdHi: now,
|
|
69
|
+
policyVersion: 1,
|
|
70
|
+
// Non-cryptographic placeholder: software mode trusts the host entirely.
|
|
71
|
+
signature: 'software-mode-unsigned',
|
|
72
|
+
},
|
|
73
|
+
holdDurationMs: input.releasePolicy === 'pin' ? 0 : this.holdDurationMs,
|
|
74
|
+
ledgerSeq: 0,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async cancelHold(input: CancelHoldRequest): Promise<void> {
|
|
79
|
+
const blob = await this.decrypt(input.envelope);
|
|
80
|
+
if (
|
|
81
|
+
blob.did !== input.expectedDid ||
|
|
82
|
+
blob.did !== input.hold.hold.did ||
|
|
83
|
+
input.clientEphemeralPublicKey !== input.hold.hold.clientEphemeralPublicKey
|
|
84
|
+
)
|
|
85
|
+
throw new EscrowPolicyError();
|
|
86
|
+
// Development only: Mongo owns cancellation; there is no software enclave ledger.
|
|
87
|
+
}
|
|
88
|
+
async getAttestation(_nonce?: Uint8Array): Promise<EnclaveAttestation> {
|
|
89
|
+
try {
|
|
90
|
+
const subtle = globalThis.crypto.subtle;
|
|
91
|
+
const key = await subtle.importKey(
|
|
92
|
+
'pkcs8',
|
|
93
|
+
new Uint8Array(Buffer.from(this.privateKeys.get(this.activeKeyId)!, 'base64')),
|
|
94
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
95
|
+
true,
|
|
96
|
+
['deriveBits']
|
|
97
|
+
);
|
|
98
|
+
// WebCrypto cannot export a private CryptoKey as SPKI. Export its public
|
|
99
|
+
// coordinates as JWK, import a public-only key, then export that as SPKI.
|
|
100
|
+
const jwk = await subtle.exportKey('jwk', key);
|
|
101
|
+
const publicKeyObject = await subtle.importKey(
|
|
102
|
+
'jwk',
|
|
103
|
+
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
|
|
104
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
105
|
+
true,
|
|
106
|
+
[]
|
|
107
|
+
);
|
|
108
|
+
const publicKey = Buffer.from(await subtle.exportKey('spki', publicKeyObject)).toString(
|
|
109
|
+
'base64'
|
|
110
|
+
);
|
|
111
|
+
const description = {
|
|
112
|
+
mode: 'software' as const,
|
|
113
|
+
keyId: this.activeKeyId,
|
|
114
|
+
// P9.1 software-enclave equivalent: every OTHER configured key is
|
|
115
|
+
// already decrypt-eligible in `decrypt()` below (keyed lookup by
|
|
116
|
+
// envelope keyId, not restricted to `activeKeyId`) — this just
|
|
117
|
+
// advertises that existing behaviour the same way the Nitro
|
|
118
|
+
// backend advertises its `ESCROW_PREVIOUS_KEY_IDS`.
|
|
119
|
+
previousKeyIds: [...this.privateKeys.keys()].filter(id => id !== this.activeKeyId),
|
|
120
|
+
publicKey,
|
|
121
|
+
issuedAt: new Date().toISOString(),
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
...description,
|
|
125
|
+
measurements: {},
|
|
126
|
+
document: Buffer.from(JSON.stringify(description)).toString('base64'),
|
|
127
|
+
};
|
|
128
|
+
} catch {
|
|
129
|
+
throw new EscrowUnavailableError();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async decrypt(envelope: unknown): Promise<EscrowBlobPlaintext> {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = parseEscrowEnvelope(envelope);
|
|
136
|
+
const key = this.privateKeys.get(parsed.keyId);
|
|
137
|
+
if (!key) throw new EscrowBlobError();
|
|
138
|
+
return await decryptEscrowBlob(parsed, key);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new EscrowBlobError();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async verifyEscrowBlob(input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> {
|
|
145
|
+
const blob = await this.decrypt(input.envelope);
|
|
146
|
+
if (blob.did !== input.expectedDid || blob.shareVersion !== input.expectedShareVersion) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
hasPin: !!blob.pinVerifier,
|
|
150
|
+
reason: 'Escrow recovery is not permitted.',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, hasPin: !!blob.pinVerifier };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Transfer only the verifier; recovery material remains sealed within the enclave.
|
|
158
|
+
*
|
|
159
|
+
* `input.sourceEnrollmentEpoch` is intentionally unused: software mode has no
|
|
160
|
+
* enclave ledger to reset in the first place, so there is no epoch-scoped PIN
|
|
161
|
+
* attempt budget to carry. The PIN attempt budget here is `escrowPin.failedAttempts`
|
|
162
|
+
* / `verifiedFailedAttempts` in MongoDB (see routes/escrow.ts), which the enroll
|
|
163
|
+
* route already copies forward unchanged on every carry, independent of
|
|
164
|
+
* `enrollmentEpoch` — so software mode never had the epoch-reset bug this field
|
|
165
|
+
* exists to fix in remote/nitro mode.
|
|
166
|
+
*/
|
|
167
|
+
async carryPinVerifier(input: CarryPinVerifierInput) {
|
|
168
|
+
const source = await this.decrypt(input.sourceEnvelope);
|
|
169
|
+
const target = await this.decrypt(input.targetEnvelope);
|
|
170
|
+
if (
|
|
171
|
+
source.did !== input.expectedDid ||
|
|
172
|
+
target.did !== input.expectedDid ||
|
|
173
|
+
source.shareVersion !== input.sourceShareVersion ||
|
|
174
|
+
target.shareVersion !== input.targetShareVersion ||
|
|
175
|
+
target.shareVersion <= source.shareVersion ||
|
|
176
|
+
!source.pinVerifier ||
|
|
177
|
+
target.pinVerifier
|
|
178
|
+
)
|
|
179
|
+
throw new EscrowBlobError();
|
|
180
|
+
const attestation = await this.getAttestation();
|
|
181
|
+
return {
|
|
182
|
+
envelope: await encryptEscrowBlob(
|
|
183
|
+
{ ...target, pinVerifier: source.pinVerifier },
|
|
184
|
+
attestation.publicKey,
|
|
185
|
+
attestation.keyId
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Software-mode equivalent of the Nitro enclave's `rewrap_escrow_blob`
|
|
192
|
+
* (P9.3): moves a blob sealed under any OTHER configured key onto the
|
|
193
|
+
* active key, unchanged otherwise. Mirrors its refusal shape — an
|
|
194
|
+
* envelope already under `activeKeyId` has nothing to migrate, and
|
|
195
|
+
* `decrypt()` already throws `EscrowBlobError` for a keyId this backend
|
|
196
|
+
* was never configured with, so both collapse into the same generic
|
|
197
|
+
* error. `sourceEnrollmentEpoch` is unused for the same reason
|
|
198
|
+
* `carryPinVerifier` ignores it above: no enclave ledger here, so no
|
|
199
|
+
* epoch-scoped PIN attempt budget to carry.
|
|
200
|
+
*/
|
|
201
|
+
async rewrapEscrowBlob(input: RewrapEscrowBlobInput) {
|
|
202
|
+
const parsed = parseEscrowEnvelope(input.envelope);
|
|
203
|
+
if (parsed.keyId === this.activeKeyId) throw new EscrowBlobError();
|
|
204
|
+
const blob = await this.decrypt(input.envelope);
|
|
205
|
+
if (blob.did !== input.expectedDid || blob.shareVersion !== input.expectedShareVersion)
|
|
206
|
+
throw new EscrowBlobError();
|
|
207
|
+
const attestation = await this.getAttestation();
|
|
208
|
+
return {
|
|
209
|
+
envelope: await encryptEscrowBlob(blob, attestation.publicKey, attestation.keyId),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult> {
|
|
214
|
+
const { hold } = input.hold;
|
|
215
|
+
if (
|
|
216
|
+
input.clientEphemeralPublicKey !== hold.clientEphemeralPublicKey ||
|
|
217
|
+
!((input.now ?? new Date()).getTime() >= hold.createdHi + input.hold.holdDurationMs)
|
|
218
|
+
)
|
|
219
|
+
throw new EscrowPolicyError();
|
|
220
|
+
const blob = await this.decrypt(input.envelope);
|
|
221
|
+
if (
|
|
222
|
+
blob.did !== input.expectedDid ||
|
|
223
|
+
blob.did !== hold.did ||
|
|
224
|
+
blob.shareVersion !== hold.shareVersion
|
|
225
|
+
)
|
|
226
|
+
throw new EscrowPolicyError();
|
|
227
|
+
if (hold.releasePolicy === 'pin') {
|
|
228
|
+
if (!blob.pinVerifier || !input.pinProof) throw new EscrowPolicyError();
|
|
229
|
+
const expected = Buffer.from(blob.pinVerifier, 'hex');
|
|
230
|
+
const actual = Buffer.from(input.pinProof, 'hex');
|
|
231
|
+
if (
|
|
232
|
+
!/^[0-9a-f]{64}$/i.test(input.pinProof) ||
|
|
233
|
+
actual.length !== expected.length ||
|
|
234
|
+
!timingSafeEqual(actual, expected)
|
|
235
|
+
)
|
|
236
|
+
throw new EscrowPinMismatchError();
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return {
|
|
240
|
+
sealed: await sealEscrowRelease(
|
|
241
|
+
{
|
|
242
|
+
recoveryShare: blob.recoveryShare,
|
|
243
|
+
did: blob.did,
|
|
244
|
+
shareVersion: blob.shareVersion,
|
|
245
|
+
holdId: hold.holdId,
|
|
246
|
+
},
|
|
247
|
+
input.clientEphemeralPublicKey
|
|
248
|
+
),
|
|
249
|
+
};
|
|
250
|
+
} catch {
|
|
251
|
+
throw new EscrowBlobError();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { EscrowEnvelope } from '@learncard/sss-key-manager';
|
|
2
|
+
// Hold records are independent of host Mongo status and deadlines.
|
|
3
|
+
|
|
4
|
+
export interface EnclaveAttestation {
|
|
5
|
+
mode: 'software' | 'nitro';
|
|
6
|
+
keyId: string;
|
|
7
|
+
/** P9.1: retired keyIds the enclave still accepts for decrypt only (never
|
|
8
|
+
* for sealing output). Empty/absent means no previous key is configured. */
|
|
9
|
+
previousKeyIds?: string[];
|
|
10
|
+
publicKey: string;
|
|
11
|
+
measurements: { imageSha384?: string; pcr0?: string; pcr1?: string; pcr2?: string };
|
|
12
|
+
document: string;
|
|
13
|
+
issuedAt: string;
|
|
14
|
+
}
|
|
15
|
+
export interface EscrowHoldRecord {
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
hold: {
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
holdId: string;
|
|
20
|
+
did: string;
|
|
21
|
+
shareVersion: number;
|
|
22
|
+
blobHash: string;
|
|
23
|
+
enrollmentEpoch: number;
|
|
24
|
+
releasePolicy: 'hold' | 'pin';
|
|
25
|
+
clientEphemeralPublicKey: string;
|
|
26
|
+
createdLo: number;
|
|
27
|
+
createdHi: number;
|
|
28
|
+
policyVersion: number;
|
|
29
|
+
signature: string;
|
|
30
|
+
};
|
|
31
|
+
holdDurationMs: number;
|
|
32
|
+
ledgerSeq: number;
|
|
33
|
+
}
|
|
34
|
+
export interface EnclaveCreateHoldInput {
|
|
35
|
+
envelope: EscrowEnvelope;
|
|
36
|
+
holdId: string;
|
|
37
|
+
expectedDid: string;
|
|
38
|
+
expectedShareVersion: number;
|
|
39
|
+
enrollmentEpoch: number;
|
|
40
|
+
releasePolicy: 'hold' | 'pin';
|
|
41
|
+
clientEphemeralPublicKey: string;
|
|
42
|
+
}
|
|
43
|
+
export interface CancelHoldRequest {
|
|
44
|
+
envelope: EscrowEnvelope;
|
|
45
|
+
hold: EscrowHoldRecord;
|
|
46
|
+
clientEphemeralPublicKey: string;
|
|
47
|
+
expectedDid: string;
|
|
48
|
+
}
|
|
49
|
+
export interface VerifyEscrowBlobInput {
|
|
50
|
+
envelope: EscrowEnvelope;
|
|
51
|
+
expectedDid: string;
|
|
52
|
+
expectedShareVersion: number;
|
|
53
|
+
}
|
|
54
|
+
export type VerifyEscrowBlobResult =
|
|
55
|
+
{ ok: true; hasPin: boolean } | { ok: false; hasPin: boolean; reason: string };
|
|
56
|
+
export interface ReleaseRequest extends CancelHoldRequest {
|
|
57
|
+
/** Development-only host clock; never forwarded by the remote backend. */
|
|
58
|
+
now?: Date;
|
|
59
|
+
pinProof?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface ReleaseResult {
|
|
62
|
+
sealed: EscrowEnvelope;
|
|
63
|
+
}
|
|
64
|
+
export interface CarryPinVerifierInput {
|
|
65
|
+
sourceEnvelope: EscrowEnvelope;
|
|
66
|
+
targetEnvelope: EscrowEnvelope;
|
|
67
|
+
expectedDid: string;
|
|
68
|
+
sourceShareVersion: number;
|
|
69
|
+
targetShareVersion: number;
|
|
70
|
+
/** Signed source-record binding; blob hash, not epoch, selects the chain. */
|
|
71
|
+
sourceEnrollmentEpoch: number;
|
|
72
|
+
/** Signed binding for the output blob's first Carried record. */
|
|
73
|
+
targetEnrollmentEpoch: number;
|
|
74
|
+
}
|
|
75
|
+
export interface RewrapEscrowBlobInput {
|
|
76
|
+
envelope: EscrowEnvelope;
|
|
77
|
+
expectedDid: string;
|
|
78
|
+
expectedShareVersion: number;
|
|
79
|
+
/** Source and destination record binding. Rewrap preserves this epoch. */
|
|
80
|
+
sourceEnrollmentEpoch: number;
|
|
81
|
+
}
|
|
82
|
+
export interface EscrowEnclave {
|
|
83
|
+
/** `nonce` binds a nitro attestation to one client-generated challenge (64 hex chars,
|
|
84
|
+
* decoded to bytes); the software backend has no freshness story and ignores it. */
|
|
85
|
+
getAttestation(nonce?: Uint8Array): Promise<EnclaveAttestation>;
|
|
86
|
+
verifyEscrowBlob(input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult>;
|
|
87
|
+
carryPinVerifier(input: CarryPinVerifierInput): Promise<{ envelope: EscrowEnvelope }>;
|
|
88
|
+
/** P9.3: migrates a copy sealed under a recognised PREVIOUS key onto the
|
|
89
|
+
* CURRENT key (same did/shareVersion/recoveryShare/pinVerifier), so a
|
|
90
|
+
* previous key can eventually be retired. Refuses (generic
|
|
91
|
+
* `EscrowBlobError`) unless the envelope's own keyId is a recognised
|
|
92
|
+
* previous key — never the current key (nothing to migrate) or an
|
|
93
|
+
* unknown one. */
|
|
94
|
+
rewrapEscrowBlob(input: RewrapEscrowBlobInput): Promise<{ envelope: EscrowEnvelope }>;
|
|
95
|
+
releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
|
|
96
|
+
createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }>;
|
|
97
|
+
cancelHold(input: CancelHoldRequest): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
export class EscrowPolicyError extends Error {
|
|
100
|
+
constructor() {
|
|
101
|
+
super('Escrow recovery is not permitted.');
|
|
102
|
+
this.name = 'EscrowPolicyError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export class EscrowPinMismatchError extends Error {
|
|
106
|
+
constructor() {
|
|
107
|
+
super('Incorrect PIN.');
|
|
108
|
+
this.name = 'EscrowPinMismatchError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class EscrowBlobError extends Error {
|
|
112
|
+
constructor() {
|
|
113
|
+
super('Invalid escrow payload.');
|
|
114
|
+
this.name = 'EscrowBlobError';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export class EscrowUnavailableError extends Error {
|
|
118
|
+
constructor() {
|
|
119
|
+
super('Escrow recovery is not available.');
|
|
120
|
+
this.name = 'EscrowUnavailableError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface SigningAuthoritySeedIdentity {
|
|
2
|
+
_id: string;
|
|
3
|
+
ownerDid: string;
|
|
4
|
+
name: string;
|
|
5
|
+
did?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EncryptedSigningAuthoritySeed {
|
|
9
|
+
encryptedSeed: string;
|
|
10
|
+
encryptedDek: string;
|
|
11
|
+
keyVersion: 'kms-v1' | 'local-v1';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Untrusted persisted fields are validated before use, including on cache hits. */
|
|
15
|
+
export interface SigningAuthoritySeedRecord {
|
|
16
|
+
_id?: unknown;
|
|
17
|
+
ownerDid: string;
|
|
18
|
+
name: string;
|
|
19
|
+
did?: string;
|
|
20
|
+
seed?: string;
|
|
21
|
+
encryptedSeed?: unknown;
|
|
22
|
+
encryptedDek?: unknown;
|
|
23
|
+
keyVersion?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SeedEncryptionConfig {
|
|
27
|
+
kmsKeyArn?: string;
|
|
28
|
+
localKek?: string;
|
|
29
|
+
allowLocal: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SeedEncryption {
|
|
33
|
+
encrypt: (
|
|
34
|
+
seed: string,
|
|
35
|
+
identity: SigningAuthoritySeedIdentity
|
|
36
|
+
) => Promise<EncryptedSigningAuthoritySeed>;
|
|
37
|
+
decrypt: (
|
|
38
|
+
envelope: EncryptedSigningAuthoritySeed,
|
|
39
|
+
identity: SigningAuthoritySeedIdentity
|
|
40
|
+
) => Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SeedEncryptionFailure =
|
|
44
|
+
| 'invalid_record'
|
|
45
|
+
| 'invalid_envelope'
|
|
46
|
+
| 'configuration'
|
|
47
|
+
| 'legacy_disabled'
|
|
48
|
+
| 'authentication_failed'
|
|
49
|
+
| 'kms_access_denied'
|
|
50
|
+
| 'kms_unavailable'
|
|
51
|
+
| 'kms_failed';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Document, ObjectId } from 'mongodb';
|
|
2
|
+
import type { EncryptedSigningAuthoritySeed } from './seed-encryption';
|
|
3
|
+
|
|
4
|
+
export type SeedMigrationPhase = 'dry-run' | 'prepare' | 'verify' | 'purge';
|
|
5
|
+
|
|
6
|
+
export interface SeedMigrationRequest {
|
|
7
|
+
phase: SeedMigrationPhase;
|
|
8
|
+
batchSize?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SeedMigrationCounts {
|
|
12
|
+
total: number;
|
|
13
|
+
encrypted: number;
|
|
14
|
+
legacyOnly: number;
|
|
15
|
+
malformed: number;
|
|
16
|
+
plaintextRemaining: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SeedMigrationResult {
|
|
20
|
+
phase: SeedMigrationPhase;
|
|
21
|
+
done: boolean;
|
|
22
|
+
processed: number;
|
|
23
|
+
counts: SeedMigrationCounts;
|
|
24
|
+
/** False for an intermediate batch: counts are the last full reconciliation snapshot. */
|
|
25
|
+
countsReconciled: boolean;
|
|
26
|
+
/** A final sweep found work inserted/changed behind the saved cursor. */
|
|
27
|
+
rescanRequired?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read the historical Mongo shape without coercing BSON IDs or changing metadata. */
|
|
31
|
+
export interface MigrationSigningAuthority extends Document {
|
|
32
|
+
_id: string | ObjectId;
|
|
33
|
+
ownerDid: string;
|
|
34
|
+
name: string;
|
|
35
|
+
did?: string;
|
|
36
|
+
seed?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SeedMigrationState {
|
|
40
|
+
_id: string;
|
|
41
|
+
leaseOwner?: string;
|
|
42
|
+
leaseExpiresAt?: Date;
|
|
43
|
+
phase?: SeedMigrationPhase;
|
|
44
|
+
epoch?: string;
|
|
45
|
+
verifiedEpoch?: string;
|
|
46
|
+
status?: 'running' | 'complete' | 'failed';
|
|
47
|
+
processed?: number;
|
|
48
|
+
counts?: SeedMigrationCounts;
|
|
49
|
+
updatedAt?: Date;
|
|
50
|
+
/** Last processed BSON ID, scoped to phase/epoch; final reconciliation rechecks earlier IDs. */
|
|
51
|
+
cursor?: string | ObjectId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Ciphertext-only receipt: exact comparisons make changed rows pending during the final sweep. */
|
|
55
|
+
export interface SeedMigrationReceipt extends EncryptedSigningAuthoritySeed {
|
|
56
|
+
_id: string | ObjectId;
|
|
57
|
+
epoch: string;
|
|
58
|
+
ownerDid: string;
|
|
59
|
+
name: string;
|
|
60
|
+
did: string | null;
|
|
61
|
+
}
|