@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,372 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { DecryptCommand, GenerateDataKeyCommand, KMSClient } from '@aws-sdk/client-kms';
|
|
3
|
+
import { MongoServerError } from 'mongodb';
|
|
4
|
+
|
|
5
|
+
import { environment } from '@environment';
|
|
6
|
+
import type {
|
|
7
|
+
EncryptedSigningAuthoritySeed,
|
|
8
|
+
SeedEncryptionConfig,
|
|
9
|
+
SeedEncryption,
|
|
10
|
+
SeedEncryptionFailure,
|
|
11
|
+
SigningAuthoritySeedIdentity,
|
|
12
|
+
SigningAuthoritySeedRecord,
|
|
13
|
+
} from 'types/seed-encryption';
|
|
14
|
+
|
|
15
|
+
export const SA_SEED_ENCRYPTION_PURPOSE = 'lca-signing-authority-seed';
|
|
16
|
+
const IV_LENGTH = 12;
|
|
17
|
+
const TAG_LENGTH = 16;
|
|
18
|
+
const KEY_LENGTH = 32;
|
|
19
|
+
const SEED_PATTERN = /^[a-fA-F0-9]{64}$/;
|
|
20
|
+
// Error names are mutable and may contain secrets; only emit known diagnostic names.
|
|
21
|
+
const SAFE_ERROR_NAMES = new Set([
|
|
22
|
+
'Error',
|
|
23
|
+
'TypeError',
|
|
24
|
+
'RangeError',
|
|
25
|
+
'ReferenceError',
|
|
26
|
+
'SyntaxError',
|
|
27
|
+
'URIError',
|
|
28
|
+
'EvalError',
|
|
29
|
+
'AggregateError',
|
|
30
|
+
'RuntimeError',
|
|
31
|
+
'CompileError',
|
|
32
|
+
'LinkError',
|
|
33
|
+
'MongoError',
|
|
34
|
+
'MongoNetworkError',
|
|
35
|
+
'MongoNetworkTimeoutError',
|
|
36
|
+
'MongoServerSelectionError',
|
|
37
|
+
'MongoOperationTimeoutError',
|
|
38
|
+
'MongoNotConnectedError',
|
|
39
|
+
'MongoTopologyClosedError',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Deliberately contains no original exception/cause: SDK and Mongo errors can carry secrets. */
|
|
43
|
+
export class SeedEncryptionError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
public readonly category: SeedEncryptionFailure,
|
|
46
|
+
public readonly awsRequestId?: string
|
|
47
|
+
) {
|
|
48
|
+
super('Signing authority key is unavailable.');
|
|
49
|
+
this.name = 'SeedEncryptionError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const getKmsFailureCategory = (name?: string): SeedEncryptionFailure => {
|
|
54
|
+
switch (name) {
|
|
55
|
+
case 'AccessDeniedException':
|
|
56
|
+
return 'kms_access_denied';
|
|
57
|
+
case 'InvalidCiphertextException':
|
|
58
|
+
return 'authentication_failed';
|
|
59
|
+
case 'TimeoutError':
|
|
60
|
+
case 'DependencyTimeoutException':
|
|
61
|
+
case 'ThrottlingException':
|
|
62
|
+
case 'KMSInternalException':
|
|
63
|
+
case 'KeyUnavailableException':
|
|
64
|
+
case 'DisabledException':
|
|
65
|
+
return 'kms_unavailable';
|
|
66
|
+
default:
|
|
67
|
+
return 'kms_failed';
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const kmsFailure = (error: unknown): SeedEncryptionError => {
|
|
72
|
+
const failure = error as { name?: string; $metadata?: { requestId?: string } } | undefined;
|
|
73
|
+
const requestId = failure?.$metadata?.requestId;
|
|
74
|
+
const safeRequestId = requestId && /^[\w-]{1,128}$/.test(requestId) ? requestId : undefined;
|
|
75
|
+
const category = getKmsFailureCategory(failure?.name);
|
|
76
|
+
return new SeedEncryptionError(category, safeRequestId);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Log only an allowlist of metadata; never pass the original error or document to a logger. */
|
|
80
|
+
export const logSeedEncryptionFailure = (
|
|
81
|
+
error: unknown,
|
|
82
|
+
operation: string,
|
|
83
|
+
record?: { _id?: unknown; keyVersion?: unknown }
|
|
84
|
+
): void => {
|
|
85
|
+
console.error({
|
|
86
|
+
event: 'signing_authority_seed_failure',
|
|
87
|
+
operation,
|
|
88
|
+
recordId: record?._id?.toString(),
|
|
89
|
+
keyVersion: ['kms-v1', 'local-v1'].includes(String(record?.keyVersion))
|
|
90
|
+
? record?.keyVersion
|
|
91
|
+
: 'unknown',
|
|
92
|
+
category: error instanceof SeedEncryptionError ? error.category : 'operation_failed',
|
|
93
|
+
awsRequestId: error instanceof SeedEncryptionError ? error.awsRequestId : undefined,
|
|
94
|
+
...(error instanceof Error && SAFE_ERROR_NAMES.has(error.name)
|
|
95
|
+
? { errorName: error.name }
|
|
96
|
+
: {}),
|
|
97
|
+
// Mongo messages/keyValue can contain the duplicate document or seed. Only log
|
|
98
|
+
// the known error class and numeric server code (e.g. 11000 for duplicate keys).
|
|
99
|
+
...(error instanceof MongoServerError
|
|
100
|
+
? {
|
|
101
|
+
errorName: 'MongoServerError',
|
|
102
|
+
mongoCode: Number.isInteger(error.code) ? error.code : undefined,
|
|
103
|
+
}
|
|
104
|
+
: {}),
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const getSeedIdentity = (record: {
|
|
109
|
+
_id?: unknown;
|
|
110
|
+
ownerDid: string;
|
|
111
|
+
name: string;
|
|
112
|
+
did?: string;
|
|
113
|
+
}): SigningAuthoritySeedIdentity => {
|
|
114
|
+
if (
|
|
115
|
+
!record._id ||
|
|
116
|
+
typeof record.ownerDid !== 'string' ||
|
|
117
|
+
typeof record.name !== 'string' ||
|
|
118
|
+
(record.did !== undefined && typeof record.did !== 'string')
|
|
119
|
+
) {
|
|
120
|
+
throw new SeedEncryptionError('invalid_record');
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
_id: String(record._id),
|
|
124
|
+
ownerDid: record.ownerDid,
|
|
125
|
+
name: record.name,
|
|
126
|
+
did: record.did,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const getContext = (
|
|
131
|
+
identity: SigningAuthoritySeedIdentity,
|
|
132
|
+
keyVersion: string
|
|
133
|
+
): Record<string, string> => ({
|
|
134
|
+
purpose: SA_SEED_ENCRYPTION_PURPOSE,
|
|
135
|
+
recordId: identity._id,
|
|
136
|
+
identity: createHash('sha256')
|
|
137
|
+
.update(JSON.stringify([identity.ownerDid, identity.name, identity.did ?? null]))
|
|
138
|
+
.digest('hex'),
|
|
139
|
+
keyVersion,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const decodeBase64 = (encoded: string, minimum: number): Buffer => {
|
|
143
|
+
const bytes = Buffer.from(encoded, 'base64');
|
|
144
|
+
if (bytes.length < minimum || bytes.toString('base64') !== encoded) {
|
|
145
|
+
throw new SeedEncryptionError('invalid_envelope');
|
|
146
|
+
}
|
|
147
|
+
return bytes;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const seal = (plaintext: Buffer, key: Buffer, aad: Buffer): string => {
|
|
151
|
+
const iv = randomBytes(IV_LENGTH);
|
|
152
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_LENGTH });
|
|
153
|
+
cipher.setAAD(aad);
|
|
154
|
+
return Buffer.concat([
|
|
155
|
+
iv,
|
|
156
|
+
cipher.update(plaintext),
|
|
157
|
+
cipher.final(),
|
|
158
|
+
cipher.getAuthTag(),
|
|
159
|
+
]).toString('base64');
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const open = (encoded: string, key: Buffer, aad: Buffer): Buffer => {
|
|
163
|
+
const bytes = decodeBase64(encoded, IV_LENGTH + TAG_LENGTH + 1);
|
|
164
|
+
let plaintextChunk: Buffer | undefined;
|
|
165
|
+
let finalChunk: Buffer | undefined;
|
|
166
|
+
try {
|
|
167
|
+
const decipher = createDecipheriv('aes-256-gcm', key, bytes.subarray(0, IV_LENGTH), {
|
|
168
|
+
authTagLength: TAG_LENGTH,
|
|
169
|
+
});
|
|
170
|
+
decipher.setAAD(aad);
|
|
171
|
+
decipher.setAuthTag(bytes.subarray(-TAG_LENGTH));
|
|
172
|
+
plaintextChunk = decipher.update(bytes.subarray(IV_LENGTH, -TAG_LENGTH));
|
|
173
|
+
finalChunk = decipher.final();
|
|
174
|
+
return Buffer.concat([plaintextChunk, finalChunk]);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new SeedEncryptionError('authentication_failed');
|
|
177
|
+
} finally {
|
|
178
|
+
// The caller clears the returned copy; clear the originals even when final() rejects the tag.
|
|
179
|
+
plaintextChunk?.fill(0);
|
|
180
|
+
finalChunk?.fill(0);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Absence means legacy only when ALL envelope fields are absent. Never downgrade corrupt envelopes. */
|
|
185
|
+
export const parseSeedEnvelope = (record: object): EncryptedSigningAuthoritySeed | undefined => {
|
|
186
|
+
const value = record as Partial<EncryptedSigningAuthoritySeed>;
|
|
187
|
+
if (!('keyVersion' in record) && !('encryptedSeed' in record) && !('encryptedDek' in record)) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
(value.keyVersion !== 'kms-v1' && value.keyVersion !== 'local-v1') ||
|
|
192
|
+
typeof value.encryptedSeed !== 'string' ||
|
|
193
|
+
typeof value.encryptedDek !== 'string'
|
|
194
|
+
) {
|
|
195
|
+
throw new SeedEncryptionError('invalid_envelope');
|
|
196
|
+
}
|
|
197
|
+
decodeBase64(value.encryptedSeed, IV_LENGTH + TAG_LENGTH + 1);
|
|
198
|
+
decodeBase64(value.encryptedDek, 1);
|
|
199
|
+
// Never return the source document: migration rows may still contain the plaintext seed.
|
|
200
|
+
return {
|
|
201
|
+
encryptedSeed: value.encryptedSeed,
|
|
202
|
+
encryptedDek: value.encryptedDek,
|
|
203
|
+
keyVersion: value.keyVersion,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* KMS takes precedence for new writes. Reads dispatch by envelope version and require the
|
|
209
|
+
* original provider's key; KMS failures never trigger a local or plaintext fallback.
|
|
210
|
+
*/
|
|
211
|
+
export const createSeedEncryption = (
|
|
212
|
+
config: SeedEncryptionConfig,
|
|
213
|
+
kms?: KMSClient
|
|
214
|
+
): SeedEncryption => {
|
|
215
|
+
const keyVersion = config.kmsKeyArn ? 'kms-v1' : 'local-v1';
|
|
216
|
+
let client = kms;
|
|
217
|
+
const getClient = (): KMSClient => {
|
|
218
|
+
// Recover transient failures on cache misses without relying on issuance callers to retry.
|
|
219
|
+
// Keep each attempt bounded by the existing connection/request timeouts.
|
|
220
|
+
client ??= new KMSClient({
|
|
221
|
+
maxAttempts: 3,
|
|
222
|
+
retryMode: 'standard',
|
|
223
|
+
requestHandler: {
|
|
224
|
+
connectionTimeout: 2_000,
|
|
225
|
+
requestTimeout: 5_000,
|
|
226
|
+
throwOnRequestTimeout: true,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
return client;
|
|
230
|
+
};
|
|
231
|
+
const getLocalKek = (): Buffer => {
|
|
232
|
+
if (!config.allowLocal || !config.localKek || !SEED_PATTERN.test(config.localKek)) {
|
|
233
|
+
throw new SeedEncryptionError('configuration');
|
|
234
|
+
}
|
|
235
|
+
return Buffer.from(config.localKek, 'hex');
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const encrypt = async (
|
|
239
|
+
seed: string,
|
|
240
|
+
identity: SigningAuthoritySeedIdentity
|
|
241
|
+
): Promise<EncryptedSigningAuthoritySeed> => {
|
|
242
|
+
if (!SEED_PATTERN.test(seed)) throw new SeedEncryptionError('invalid_record');
|
|
243
|
+
const context = getContext(identity, keyVersion);
|
|
244
|
+
const aad = Buffer.from(JSON.stringify(context));
|
|
245
|
+
const plaintext = Buffer.from(seed, 'utf8');
|
|
246
|
+
let dek: Buffer | undefined;
|
|
247
|
+
let kmsPlaintext: Uint8Array | undefined;
|
|
248
|
+
let kek: Buffer | undefined;
|
|
249
|
+
try {
|
|
250
|
+
let encryptedDek: string;
|
|
251
|
+
if (config.kmsKeyArn) {
|
|
252
|
+
const result = await getClient()
|
|
253
|
+
.send(
|
|
254
|
+
new GenerateDataKeyCommand({
|
|
255
|
+
KeyId: config.kmsKeyArn,
|
|
256
|
+
KeySpec: 'AES_256',
|
|
257
|
+
EncryptionContext: context,
|
|
258
|
+
})
|
|
259
|
+
)
|
|
260
|
+
.catch(error => {
|
|
261
|
+
throw kmsFailure(error);
|
|
262
|
+
});
|
|
263
|
+
kmsPlaintext = result.Plaintext;
|
|
264
|
+
if (
|
|
265
|
+
kmsPlaintext?.length !== KEY_LENGTH ||
|
|
266
|
+
!result.CiphertextBlob?.length ||
|
|
267
|
+
result.KeyId !== config.kmsKeyArn
|
|
268
|
+
) {
|
|
269
|
+
throw new SeedEncryptionError('kms_failed');
|
|
270
|
+
}
|
|
271
|
+
dek = Buffer.from(kmsPlaintext);
|
|
272
|
+
encryptedDek = Buffer.from(result.CiphertextBlob).toString('base64');
|
|
273
|
+
} else {
|
|
274
|
+
kek = getLocalKek();
|
|
275
|
+
dek = randomBytes(KEY_LENGTH);
|
|
276
|
+
encryptedDek = seal(dek, kek, Buffer.concat([aad, Buffer.from('/dek')]));
|
|
277
|
+
}
|
|
278
|
+
return { encryptedSeed: seal(plaintext, dek, aad), encryptedDek, keyVersion };
|
|
279
|
+
} finally {
|
|
280
|
+
plaintext.fill(0);
|
|
281
|
+
dek?.fill(0);
|
|
282
|
+
kmsPlaintext?.fill(0);
|
|
283
|
+
kek?.fill(0);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const decrypt = async (
|
|
288
|
+
envelope: EncryptedSigningAuthoritySeed,
|
|
289
|
+
identity: SigningAuthoritySeedIdentity
|
|
290
|
+
): Promise<string> => {
|
|
291
|
+
parseSeedEnvelope(envelope);
|
|
292
|
+
const context = getContext(identity, envelope.keyVersion);
|
|
293
|
+
const aad = Buffer.from(JSON.stringify(context));
|
|
294
|
+
let dek: Buffer | undefined;
|
|
295
|
+
let kmsPlaintext: Uint8Array | undefined;
|
|
296
|
+
let plaintext: Buffer | undefined;
|
|
297
|
+
let kek: Buffer | undefined;
|
|
298
|
+
try {
|
|
299
|
+
if (envelope.keyVersion === 'kms-v1') {
|
|
300
|
+
if (!config.kmsKeyArn) throw new SeedEncryptionError('configuration');
|
|
301
|
+
const result = await getClient()
|
|
302
|
+
.send(
|
|
303
|
+
new DecryptCommand({
|
|
304
|
+
KeyId: config.kmsKeyArn,
|
|
305
|
+
CiphertextBlob: decodeBase64(envelope.encryptedDek, 1),
|
|
306
|
+
EncryptionContext: context,
|
|
307
|
+
EncryptionAlgorithm: 'SYMMETRIC_DEFAULT',
|
|
308
|
+
})
|
|
309
|
+
)
|
|
310
|
+
.catch(error => {
|
|
311
|
+
throw kmsFailure(error);
|
|
312
|
+
});
|
|
313
|
+
kmsPlaintext = result.Plaintext;
|
|
314
|
+
if (kmsPlaintext?.length !== KEY_LENGTH || result.KeyId !== config.kmsKeyArn) {
|
|
315
|
+
throw new SeedEncryptionError('kms_failed');
|
|
316
|
+
}
|
|
317
|
+
dek = Buffer.from(kmsPlaintext);
|
|
318
|
+
} else {
|
|
319
|
+
kek = getLocalKek();
|
|
320
|
+
dek = open(envelope.encryptedDek, kek, Buffer.concat([aad, Buffer.from('/dek')]));
|
|
321
|
+
if (dek.length !== KEY_LENGTH) throw new SeedEncryptionError('invalid_envelope');
|
|
322
|
+
}
|
|
323
|
+
plaintext = open(envelope.encryptedSeed, dek, aad);
|
|
324
|
+
const seed = plaintext.toString('utf8');
|
|
325
|
+
if (!SEED_PATTERN.test(seed)) throw new SeedEncryptionError('invalid_record');
|
|
326
|
+
return seed;
|
|
327
|
+
} finally {
|
|
328
|
+
dek?.fill(0);
|
|
329
|
+
kmsPlaintext?.fill(0);
|
|
330
|
+
plaintext?.fill(0);
|
|
331
|
+
kek?.fill(0);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
return { encrypt, decrypt };
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export const seedEncryption = createSeedEncryption({
|
|
338
|
+
kmsKeyArn: environment.SA_SEED_KMS_KEY_ARN,
|
|
339
|
+
localKek: environment.SA_SEED_LOCAL_KEK,
|
|
340
|
+
allowLocal: environment.IS_OFFLINE || environment.NODE_ENV === 'test',
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/** Temporary dual reader; remove the legacy branch after the verified production migration. */
|
|
344
|
+
export const decryptSigningAuthoritySeed = async (
|
|
345
|
+
record: SigningAuthoritySeedRecord
|
|
346
|
+
): Promise<string> => {
|
|
347
|
+
const envelope = parseSeedEnvelope(record);
|
|
348
|
+
if (envelope) return seedEncryption.decrypt(envelope, getSeedIdentity(record));
|
|
349
|
+
if (!environment.SA_SEED_ALLOW_LEGACY_READ) throw new SeedEncryptionError('legacy_disabled');
|
|
350
|
+
if (!record.seed || !SEED_PATTERN.test(record.seed))
|
|
351
|
+
throw new SeedEncryptionError('invalid_record');
|
|
352
|
+
return record.seed;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** Non-secret cache fingerprint. Hash legacy seeds only during the compatibility window. */
|
|
356
|
+
export const getSeedCacheFingerprint = (record: SigningAuthoritySeedRecord): string => {
|
|
357
|
+
const envelope = parseSeedEnvelope(record);
|
|
358
|
+
if (!envelope && !environment.SA_SEED_ALLOW_LEGACY_READ) {
|
|
359
|
+
throw new SeedEncryptionError('legacy_disabled');
|
|
360
|
+
}
|
|
361
|
+
return createHash('sha256')
|
|
362
|
+
.update(
|
|
363
|
+
JSON.stringify([
|
|
364
|
+
String(record._id),
|
|
365
|
+
record.ownerDid,
|
|
366
|
+
record.name,
|
|
367
|
+
record.did,
|
|
368
|
+
envelope ?? record.seed,
|
|
369
|
+
])
|
|
370
|
+
)
|
|
371
|
+
.digest('hex');
|
|
372
|
+
};
|
|
@@ -12,7 +12,10 @@ export const getSigningAuthorityWithEndpoint = (
|
|
|
12
12
|
domainName: string
|
|
13
13
|
): SigningAuthorityResponseType => {
|
|
14
14
|
return {
|
|
15
|
-
|
|
15
|
+
_id: signingAuthority._id,
|
|
16
|
+
name: signingAuthority.name,
|
|
17
|
+
ownerDid: signingAuthority.ownerDid,
|
|
18
|
+
did: signingAuthority.did,
|
|
16
19
|
endpoint: getEndpoint(domainName),
|
|
17
20
|
};
|
|
18
21
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Google / Apple `id_token` verification for the lca-api auth router
|
|
3
|
+
* (AD-2 / AD-3).
|
|
4
|
+
*
|
|
5
|
+
* The native app obtains an Apple/Google `id_token` through the platform SDK
|
|
6
|
+
* and posts it here. We verify it against the provider's published JWKS — exact
|
|
7
|
+
* issuer + audience (client id) allowlists, signature, `exp`/`nbf` — before
|
|
8
|
+
* issuing a login ticket. A test seam (`setSocialJwksResolverForTests`) swaps
|
|
9
|
+
* the remote JWKS for a local key set so the flow is unit-testable without
|
|
10
|
+
* network access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { environment } from '@environment';
|
|
14
|
+
import { TRPCError } from '@trpc/server';
|
|
15
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
16
|
+
import type { JWTVerifyGetKey } from 'jose';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
export type SocialProviderId = 'google' | 'apple';
|
|
20
|
+
|
|
21
|
+
export interface SocialProviderConfig {
|
|
22
|
+
issuers: string[];
|
|
23
|
+
audiences: string[];
|
|
24
|
+
jwksUri: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SocialClaims {
|
|
28
|
+
sub: string;
|
|
29
|
+
iss: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
email_verified?: boolean;
|
|
32
|
+
name?: string;
|
|
33
|
+
picture?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GOOGLE_DEFAULT_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
|
|
37
|
+
const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
|
|
38
|
+
const APPLE_DEFAULT_ISSUERS = ['https://appleid.apple.com'];
|
|
39
|
+
const APPLE_JWKS_URI = 'https://appleid.apple.com/auth/keys';
|
|
40
|
+
|
|
41
|
+
const claimsSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
sub: z.string().min(1),
|
|
44
|
+
iss: z.string().min(1),
|
|
45
|
+
email: z.string().min(1).optional(),
|
|
46
|
+
email_verified: z.union([z.boolean(), z.string()]).optional(),
|
|
47
|
+
name: z.string().optional(),
|
|
48
|
+
picture: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.passthrough();
|
|
51
|
+
|
|
52
|
+
const splitCsv = (value: string | undefined): string[] =>
|
|
53
|
+
(value ?? '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map(entry => entry.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
export const getSocialProviderConfig = (
|
|
59
|
+
provider: SocialProviderId
|
|
60
|
+
): SocialProviderConfig | null => {
|
|
61
|
+
if (provider === 'google') {
|
|
62
|
+
const audiences = splitCsv(environment.GOOGLE_OAUTH_CLIENT_IDS);
|
|
63
|
+
if (audiences.length === 0) return null;
|
|
64
|
+
return {
|
|
65
|
+
audiences,
|
|
66
|
+
issuers: GOOGLE_DEFAULT_ISSUERS,
|
|
67
|
+
jwksUri: GOOGLE_JWKS_URI,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const audiences = splitCsv(environment.APPLE_OAUTH_CLIENT_IDS);
|
|
72
|
+
if (audiences.length === 0) return null;
|
|
73
|
+
return {
|
|
74
|
+
audiences,
|
|
75
|
+
issuers: APPLE_DEFAULT_ISSUERS,
|
|
76
|
+
jwksUri: APPLE_JWKS_URI,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const jwksByUri = new Map<string, JWTVerifyGetKey>();
|
|
81
|
+
let resolverForTests: ((jwksUri: string) => JWTVerifyGetKey) | undefined;
|
|
82
|
+
|
|
83
|
+
export const setSocialJwksResolverForTests = (
|
|
84
|
+
resolver: ((jwksUri: string) => JWTVerifyGetKey) | undefined
|
|
85
|
+
): void => {
|
|
86
|
+
resolverForTests = resolver;
|
|
87
|
+
jwksByUri.clear();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const getSocialJwks = (jwksUri: string): JWTVerifyGetKey => {
|
|
91
|
+
let jwks = jwksByUri.get(jwksUri);
|
|
92
|
+
if (!jwks) {
|
|
93
|
+
jwks = resolverForTests ? resolverForTests(jwksUri) : createRemoteJWKSet(new URL(jwksUri));
|
|
94
|
+
jwksByUri.set(jwksUri, jwks);
|
|
95
|
+
}
|
|
96
|
+
return jwks;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const verifySocialIdToken = async (
|
|
100
|
+
provider: SocialProviderId,
|
|
101
|
+
token: string
|
|
102
|
+
): Promise<SocialClaims> => {
|
|
103
|
+
const config = getSocialProviderConfig(provider);
|
|
104
|
+
if (!config) {
|
|
105
|
+
throw new TRPCError({
|
|
106
|
+
code: 'UNAUTHORIZED',
|
|
107
|
+
message: `Sign-in with ${provider} is not configured.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload: unknown;
|
|
112
|
+
try {
|
|
113
|
+
({ payload } = await jwtVerify(token, getSocialJwks(config.jwksUri), {
|
|
114
|
+
issuer: config.issuers,
|
|
115
|
+
audience: config.audiences,
|
|
116
|
+
algorithms: ['RS256', 'ES256'],
|
|
117
|
+
requiredClaims: ['sub', 'exp', 'email', 'email_verified'],
|
|
118
|
+
}));
|
|
119
|
+
} catch {
|
|
120
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = claimsSchema.safeParse(payload);
|
|
124
|
+
if (!result.success) {
|
|
125
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const claims = result.data;
|
|
129
|
+
const emailVerified =
|
|
130
|
+
claims.email_verified === true ||
|
|
131
|
+
(provider === 'apple' && claims.email_verified === 'true');
|
|
132
|
+
if (!claims.email || !emailVerified) {
|
|
133
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
sub: claims.sub,
|
|
138
|
+
iss: claims.iss,
|
|
139
|
+
email: claims.email,
|
|
140
|
+
email_verified: emailVerified,
|
|
141
|
+
name: claims.name,
|
|
142
|
+
picture: claims.picture,
|
|
143
|
+
};
|
|
144
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findPendingEscrowHoldByAuthProvider,
|
|
3
|
+
findUserKeysWithEscrowBlobKeyId,
|
|
4
|
+
rewrapEscrowBlobByAuthProvider,
|
|
5
|
+
} from '@models';
|
|
6
|
+
import {
|
|
7
|
+
getEnclaveAttestationIdentity,
|
|
8
|
+
getEscrowEnclave,
|
|
9
|
+
isEscrowEnabled,
|
|
10
|
+
} from '../services/escrow-enclave';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_REWRAP_BATCH_LIMIT = 200;
|
|
13
|
+
|
|
14
|
+
export interface EscrowBlobRewrapResult {
|
|
15
|
+
rewrapped: number;
|
|
16
|
+
skippedPendingHold: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RunEscrowBlobRewrapOptions {
|
|
21
|
+
limit?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deliberately drops the caught error's message, not just its stack: a real
|
|
26
|
+
* enclave/decrypt failure could embed plaintext or a proof in its own
|
|
27
|
+
* message text, so forwarding it here would leak secrets through the one
|
|
28
|
+
* logging path meant to report the failure. Mirrors
|
|
29
|
+
* `escrowHoldReminders.ts`'s `logJobError`, which makes the same trade-off
|
|
30
|
+
* for the same reason. `userKeyId` (a Mongo id, not a DID/email) is safe to
|
|
31
|
+
* log for triage.
|
|
32
|
+
*/
|
|
33
|
+
const logJobError = (userKeyId: string | undefined, error: unknown): void => {
|
|
34
|
+
console.error('[escrow-blob-rewrap] failed to process user key', {
|
|
35
|
+
userKeyId,
|
|
36
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scheduled maintenance job (P9.3): migrates escrow blobs still sealed
|
|
42
|
+
* under a previous enclave key onto the current key, so that key can
|
|
43
|
+
* eventually be retired. Covers accounts that never sign in again after a
|
|
44
|
+
* key rotation — those already migrate client-side on next login via P9.2's
|
|
45
|
+
* carry-on-enroll path.
|
|
46
|
+
*
|
|
47
|
+
* No-ops when escrow is disabled for this stage/tenant, or when the
|
|
48
|
+
* enclave's current (cached) identity reports no previous keys at all —
|
|
49
|
+
* the common case once every previous key has been fully retired. Skips
|
|
50
|
+
* (does not touch) any account with a pending recovery hold: the signed
|
|
51
|
+
* hold record binds the OLD blob's hash/epoch, so re-sealing mid-hold would
|
|
52
|
+
* break an in-progress recovery; those accounts are simply re-tried on a
|
|
53
|
+
* later run once their hold resolves. Every write is an atomic
|
|
54
|
+
* compare-and-swap (`rewrapEscrowBlobByAuthProvider`); losing that race to a
|
|
55
|
+
* concurrent re-enroll/carry/opt-out is a silent no-op, not a failure.
|
|
56
|
+
*/
|
|
57
|
+
export const runEscrowBlobRewrap = async (
|
|
58
|
+
options: RunEscrowBlobRewrapOptions = {}
|
|
59
|
+
): Promise<EscrowBlobRewrapResult> => {
|
|
60
|
+
const empty: EscrowBlobRewrapResult = { rewrapped: 0, skippedPendingHold: 0, failed: 0 };
|
|
61
|
+
if (!isEscrowEnabled()) return empty;
|
|
62
|
+
|
|
63
|
+
// Cheap, cache-aware gate: avoids a real enclave round trip on every
|
|
64
|
+
// run once there is nothing left to migrate (see services/escrow-enclave/
|
|
65
|
+
// index.ts's `getEnclaveAttestationIdentity`).
|
|
66
|
+
const identity = await getEnclaveAttestationIdentity().catch(() => undefined);
|
|
67
|
+
if (!identity || identity.previousKeyIds.length === 0) return empty;
|
|
68
|
+
|
|
69
|
+
const limit = options.limit ?? DEFAULT_REWRAP_BATCH_LIMIT;
|
|
70
|
+
const candidates = await findUserKeysWithEscrowBlobKeyId(identity.previousKeyIds, limit);
|
|
71
|
+
if (candidates.length === 0) return empty;
|
|
72
|
+
|
|
73
|
+
// A fresh, full attestation call (not just the cached identity) for the
|
|
74
|
+
// measurements the migrated blob must carry; only fetched once there is
|
|
75
|
+
// actually something to migrate this run.
|
|
76
|
+
const attestation = await getEscrowEnclave().getAttestation();
|
|
77
|
+
|
|
78
|
+
let rewrapped = 0;
|
|
79
|
+
let skippedPendingHold = 0;
|
|
80
|
+
let failed = 0;
|
|
81
|
+
|
|
82
|
+
for (const userKey of candidates) {
|
|
83
|
+
const blob = userKey.escrowBlob;
|
|
84
|
+
const authProvider = userKey.authProviders[0];
|
|
85
|
+
// Already migrated (by this loop's own attestation refresh landing
|
|
86
|
+
// mid-batch, or a concurrent run) since the candidate query ran.
|
|
87
|
+
if (!blob || !authProvider || blob.enclaveKeyId === attestation.keyId) continue;
|
|
88
|
+
try {
|
|
89
|
+
if (await findPendingEscrowHoldByAuthProvider(authProvider)) {
|
|
90
|
+
skippedPendingHold += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const { envelope } = await getEscrowEnclave().rewrapEscrowBlob({
|
|
94
|
+
envelope: blob.envelope,
|
|
95
|
+
expectedDid: userKey.primaryDid,
|
|
96
|
+
expectedShareVersion: blob.shareVersion,
|
|
97
|
+
sourceEnrollmentEpoch: blob.enrollmentEpoch,
|
|
98
|
+
});
|
|
99
|
+
const stored = await rewrapEscrowBlobByAuthProvider(
|
|
100
|
+
authProvider,
|
|
101
|
+
blob,
|
|
102
|
+
userKey.escrowPin,
|
|
103
|
+
envelope,
|
|
104
|
+
{
|
|
105
|
+
enclaveKeyId: attestation.keyId,
|
|
106
|
+
enclaveMode: attestation.mode,
|
|
107
|
+
measurements: attestation.measurements,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
if (stored) rewrapped += 1;
|
|
111
|
+
// else: lost the compare-and-swap race (a concurrent re-enroll,
|
|
112
|
+
// PIN change or opt-out since this candidate was read) — not a
|
|
113
|
+
// failure, just re-tried on a later run if still applicable.
|
|
114
|
+
} catch (error) {
|
|
115
|
+
failed += 1;
|
|
116
|
+
logJobError(userKey._id, error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { rewrapped, skippedPendingHold, failed };
|
|
121
|
+
};
|