gdc-sdk-node-ts 2.1.4 → 2.3.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/README.md +11 -5
- package/dist/backend-profile-runtime.d.ts +23 -0
- package/dist/backend-profile-runtime.js +23 -0
- package/dist/backend-profile-workspace.d.ts +48 -0
- package/dist/backend-profile-workspace.js +91 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/individual-controller-backend-runtime.d.ts +6 -1
- package/dist/individual-controller-backend-runtime.js +7 -0
- package/dist/node-runtime-client.d.ts +51 -4
- package/dist/node-runtime-client.js +148 -4
- package/dist/orchestration/client-port.d.ts +6 -1
- package/dist/orchestration/individual-controller-sdk.d.ts +46 -1
- package/dist/orchestration/individual-controller-sdk.js +59 -0
- package/dist/orchestration/individual-member-sdk.d.ts +35 -1
- package/dist/orchestration/individual-member-sdk.js +44 -0
- package/dist/orchestration/personal-sdk.d.ts +3 -1
- package/dist/orchestration/personal-sdk.js +4 -0
- package/dist/orchestration/professional-sdk.d.ts +11 -1
- package/dist/orchestration/professional-sdk.js +14 -0
- package/dist/professional-backend-runtime.d.ts +6 -1
- package/dist/professional-backend-runtime.js +7 -0
- package/dist/profile-workspace.d.ts +12 -0
- package/dist/profile-workspace.js +15 -0
- package/dist/resource-operations.d.ts +185 -4
- package/dist/resource-operations.js +267 -3
- package/dist/runtime-client-paths.d.ts +8 -2
- package/dist/runtime-client-paths.js +6 -0
- package/dist/runtime-route-context.js +18 -1
- package/dist/runtime-transport.d.ts +13 -1
- package/dist/runtime-transport.js +18 -3
- package/dist/server-profile-protection.d.ts +64 -0
- package/dist/server-profile-protection.js +110 -0
- package/dist/server-profile-session.d.ts +115 -0
- package/dist/server-profile-session.js +259 -0
- package/dist/session.d.ts +7 -0
- package/dist/session.js +10 -0
- package/package.json +3 -3
- package/dist/legal-organization-onboarding-facade.d.ts +0 -70
- package/dist/legal-organization-onboarding-facade.js +0 -169
- package/dist/lifecycle-examples.d.ts +0 -7
- package/dist/lifecycle-examples.js +0 -8
- package/dist/runtime-software-proof.d.ts +0 -51
- package/dist/runtime-software-proof.js +0 -34
- package/dist/simple-device-activation.d.ts +0 -44
- package/dist/simple-device-activation.js +0 -44
- package/dist/simple-host-onboarding.d.ts +0 -27
- package/dist/simple-host-onboarding.js +0 -39
- package/dist/simple-individual-onboarding.d.ts +0 -27
- package/dist/simple-individual-onboarding.js +0 -38
- package/dist/simple-individual-start.d.ts +0 -45
- package/dist/simple-individual-start.js +0 -59
- package/dist/simple-poll-options.d.ts +0 -5
- package/dist/simple-poll-options.js +0 -17
- package/dist/simple-smart-token.d.ts +0 -58
- package/dist/simple-smart-token.js +0 -85
|
@@ -66,6 +66,10 @@ export class RuntimeClientPaths {
|
|
|
66
66
|
individualFamilyOrganizationPurgePollPath(ctx) { return this.v1Path(ctx, 'individual', 'org.schema', 'Organization', `${GwCoreLifecycleAction.Purge}-response`); }
|
|
67
67
|
individualLicenseSearchPath(ctx) { return this.v1Path(ctx, 'individual', 'org.schema', 'License', '_search'); }
|
|
68
68
|
individualLicenseSearchPollPath(ctx) { return this.v1Path(ctx, 'individual', 'org.schema', 'License', '_search-response'); }
|
|
69
|
+
/** Builds one individual-member license mutation path. */
|
|
70
|
+
individualLicenseActionPath(ctx, action) { return this.v1Path(ctx, 'individual', 'org.schema', 'License', action); }
|
|
71
|
+
/** Builds the poll path paired with one individual-member license mutation. */
|
|
72
|
+
individualLicenseActionPollPath(ctx, action) { return this.v1Path(ctx, 'individual', 'org.schema', 'License', `${action}-response`); }
|
|
69
73
|
individualLicenseOfferSearchPath(ctx) { return this.v1Path(ctx, 'individual', 'org.schema', 'Offer', '_search'); }
|
|
70
74
|
individualLicenseOfferSearchPollPath(ctx) { return this.v1Path(ctx, 'individual', 'org.schema', 'Offer', '_search-response'); }
|
|
71
75
|
individualLicenseOrderSearchPath(ctx) { return this.v1Path(ctx, 'individual', 'org.schema', 'Order', '_search'); }
|
|
@@ -82,6 +86,8 @@ export class RuntimeClientPaths {
|
|
|
82
86
|
individualCommunicationPollPath(ctx, format) { return this.v1Path(ctx, 'individual', format, 'Communication', '_batch-response'); }
|
|
83
87
|
individualCommunicationSearchPath(ctx) { return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Communication', '_search'); }
|
|
84
88
|
individualCommunicationSearchPollPath(ctx) { return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Communication', '_search-response'); }
|
|
89
|
+
individualDocumentReferenceBatchPath(ctx) { return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'DocumentReference', '_batch'); }
|
|
90
|
+
individualDocumentReferencePollPath(ctx) { return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'DocumentReference', '_batch-response'); }
|
|
85
91
|
individualBundleSearchPath(ctx) { return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Bundle', '_search'); }
|
|
86
92
|
individualBundleSearchPollPath(ctx) { return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Bundle', '_search-response'); }
|
|
87
93
|
identityTokenExchangePath(ctx) { return buildIdentityTokenExchangePath(ctx); }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
|
|
2
|
+
import { extractTenantIdFromHostedDidWeb } from 'gdc-common-utils-ts';
|
|
3
|
+
let warnedServiceProviderDidRouteTenant = false;
|
|
2
4
|
export function requireRouteContext(ctx, defaultCtx) {
|
|
3
5
|
const resolved = ctx || defaultCtx;
|
|
4
6
|
const tenantId = String(resolved?.tenantId || '').trim();
|
|
@@ -10,7 +12,22 @@ export function requireRouteContext(ctx, defaultCtx) {
|
|
|
10
12
|
return { tenantId, jurisdiction, sector };
|
|
11
13
|
}
|
|
12
14
|
export function routeCtxFromInput(input, defaultCtx) {
|
|
13
|
-
const
|
|
15
|
+
const explicitTenantId = String(input.tenantId || '').trim();
|
|
16
|
+
const serviceProviderDid = String(input.serviceProviderDid || '').trim();
|
|
17
|
+
let tenantId = explicitTenantId;
|
|
18
|
+
if (!tenantId && serviceProviderDid) {
|
|
19
|
+
const extractedTenantId = extractTenantIdFromHostedDidWeb(serviceProviderDid);
|
|
20
|
+
if (extractedTenantId) {
|
|
21
|
+
if (!warnedServiceProviderDidRouteTenant) {
|
|
22
|
+
warnedServiceProviderDidRouteTenant = true;
|
|
23
|
+
console.warn(`[gdc-sdk-node-ts] serviceProviderDid is a compatibility alias for route tenant ids. Pass tenantId instead. Extracted tenantId='${extractedTenantId}' from hosted did:web '${serviceProviderDid}'.`);
|
|
24
|
+
}
|
|
25
|
+
tenantId = extractedTenantId;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
tenantId = serviceProviderDid;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
14
31
|
return requireRouteContext(tenantId && input.jurisdiction && input.sector
|
|
15
32
|
? { tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
|
|
16
33
|
: undefined, defaultCtx);
|
|
@@ -4,8 +4,9 @@ export type RuntimeTransportConfig = Readonly<{
|
|
|
4
4
|
defaultHeaders: Record<string, string>;
|
|
5
5
|
requestTimeoutMs: number;
|
|
6
6
|
httpTraceFile?: string;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
7
8
|
}>;
|
|
8
|
-
export declare function buildRuntimeHeaders(config: RuntimeTransportConfig, contentType: string): Record<string, string>;
|
|
9
|
+
export declare function buildRuntimeHeaders(config: RuntimeTransportConfig, contentType: string, accept?: string): Record<string, string>;
|
|
9
10
|
export declare function pollBatchResponseWithRuntimeConfig(config: RuntimeTransportConfig, path: string, request: {
|
|
10
11
|
thid: string;
|
|
11
12
|
}): Promise<{
|
|
@@ -18,5 +19,16 @@ export declare function postJsonWithRuntimeConfig(config: RuntimeTransportConfig
|
|
|
18
19
|
location?: string;
|
|
19
20
|
body: unknown;
|
|
20
21
|
}>;
|
|
22
|
+
/** Submits an already-rendered JSON, FHIR, DIDComm or secure-form request. */
|
|
23
|
+
export declare function postRenderedWithRuntimeConfig(config: RuntimeTransportConfig, path: string, request: {
|
|
24
|
+
contentType: string;
|
|
25
|
+
accept: string;
|
|
26
|
+
body: Record<string, unknown> | string;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
status: number;
|
|
29
|
+
location?: string;
|
|
30
|
+
body: unknown;
|
|
31
|
+
retryAfterMs?: number;
|
|
32
|
+
}>;
|
|
21
33
|
export declare function fetchWithTimeout(config: RuntimeTransportConfig, path: string, init: RequestInit): Promise<Response>;
|
|
22
34
|
export declare function parseResponseBody(response: Response): Promise<unknown>;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
|
|
2
2
|
import { DIDCOMM_DEFAULT_ACCEPT_HEADER } from 'gdc-common-utils-ts/utils/didcomm-submit';
|
|
3
3
|
import { appendHttpTrace, parseTraceBody, parseTraceRawText, redactTraceValue, } from './runtime-http-trace.js';
|
|
4
|
-
export function buildRuntimeHeaders(config, contentType) {
|
|
4
|
+
export function buildRuntimeHeaders(config, contentType, accept = DIDCOMM_DEFAULT_ACCEPT_HEADER) {
|
|
5
5
|
const headers = {
|
|
6
6
|
...config.defaultHeaders,
|
|
7
7
|
'Content-Type': contentType,
|
|
8
|
-
Accept:
|
|
8
|
+
Accept: accept,
|
|
9
9
|
};
|
|
10
10
|
if (config.bearerToken)
|
|
11
11
|
headers.Authorization = `Bearer ${config.bearerToken}`;
|
|
@@ -36,6 +36,21 @@ export async function postJsonWithRuntimeConfig(config, path, payload, contentTy
|
|
|
36
36
|
body: await parseResponseBody(response),
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
+
/** Submits an already-rendered JSON, FHIR, DIDComm or secure-form request. */
|
|
40
|
+
export async function postRenderedWithRuntimeConfig(config, path, request) {
|
|
41
|
+
const response = await fetchWithTimeout(config, path, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: buildRuntimeHeaders(config, request.contentType, request.accept),
|
|
44
|
+
body: typeof request.body === 'string' ? request.body : JSON.stringify(request.body),
|
|
45
|
+
});
|
|
46
|
+
const retryAfter = Number(response.headers.get('retry-after'));
|
|
47
|
+
return {
|
|
48
|
+
status: response.status,
|
|
49
|
+
location: response.headers.get('location') || undefined,
|
|
50
|
+
body: await parseResponseBody(response),
|
|
51
|
+
retryAfterMs: Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
39
54
|
export async function fetchWithTimeout(config, path, init) {
|
|
40
55
|
const controller = new AbortController();
|
|
41
56
|
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
|
|
@@ -51,7 +66,7 @@ export async function fetchWithTimeout(config, path, init) {
|
|
|
51
66
|
},
|
|
52
67
|
};
|
|
53
68
|
try {
|
|
54
|
-
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
69
|
+
const response = await (config.fetchImpl || fetch)(url, { ...init, signal: controller.signal });
|
|
55
70
|
const responseClone = response.clone();
|
|
56
71
|
const responseRaw = await responseClone.text();
|
|
57
72
|
appendHttpTrace(config.httpTraceFile, {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-controlled KEK boundary.
|
|
3
|
+
*
|
|
4
|
+
* A BFF normally implements this with Cloud KMS. A native confidential app
|
|
5
|
+
* implements the same boundary with a non-exportable Keychain/Keystore key.
|
|
6
|
+
* The adapter never receives the user's PIN.
|
|
7
|
+
*/
|
|
8
|
+
export type ServerProfileSealer = Readonly<{
|
|
9
|
+
seal(cleartext: string, aad: string): Promise<string>;
|
|
10
|
+
unseal(ciphertext: string, aad: string): Promise<string>;
|
|
11
|
+
}>;
|
|
12
|
+
/** Persisted scrypt work factor. The salt and parameters are public metadata. */
|
|
13
|
+
export type ProfileScryptParameters = Readonly<{
|
|
14
|
+
name: 'scrypt';
|
|
15
|
+
saltBase64Url: string;
|
|
16
|
+
cost: number;
|
|
17
|
+
blockSize: number;
|
|
18
|
+
parallelization: number;
|
|
19
|
+
keyLength: 32;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* Portable v1 envelope for a profile secret.
|
|
23
|
+
*
|
|
24
|
+
* `ciphertext` contains the secret encrypted by a random DEK. The host first
|
|
25
|
+
* wraps that DEK; the PIN-derived key then encrypts the host-wrapped value.
|
|
26
|
+
* Opening therefore requires both factors without exposing either factor to
|
|
27
|
+
* the other adapter.
|
|
28
|
+
*/
|
|
29
|
+
export type PinProtectedProfileSecret = Readonly<{
|
|
30
|
+
version: 'gdc-pin-host-envelope-v1';
|
|
31
|
+
kdf: ProfileScryptParameters;
|
|
32
|
+
payload: AesGcmCiphertext;
|
|
33
|
+
pinWrappedHostDek: AesGcmCiphertext;
|
|
34
|
+
}>;
|
|
35
|
+
export type AesGcmCiphertext = Readonly<{
|
|
36
|
+
ivBase64Url: string;
|
|
37
|
+
ciphertextBase64Url: string;
|
|
38
|
+
tagBase64Url: string;
|
|
39
|
+
}>;
|
|
40
|
+
export type ProfileProtectionOptions = Readonly<{
|
|
41
|
+
cost?: number;
|
|
42
|
+
blockSize?: number;
|
|
43
|
+
parallelization?: number;
|
|
44
|
+
}>;
|
|
45
|
+
/** Distinguishes a wrong PIN from a host KMS outage or corrupted payload. */
|
|
46
|
+
export declare class ProfilePinRejectedError extends Error {
|
|
47
|
+
constructor();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Encrypt one seed, private-key export or credential using a fresh DEK.
|
|
51
|
+
*
|
|
52
|
+
* The returned salt is intentionally stored in clear. Security comes from the
|
|
53
|
+
* PIN work factor, the independent host KEK and authenticated encryption, not
|
|
54
|
+
* from hiding the salt or algorithm parameters.
|
|
55
|
+
*/
|
|
56
|
+
export declare function protectServerProfileSecret(cleartext: string, pin: string, aad: string, hostSealer: ServerProfileSealer, options?: ProfileProtectionOptions): Promise<PinProtectedProfileSecret>;
|
|
57
|
+
/**
|
|
58
|
+
* Open one protected profile secret using both PIN and host protection.
|
|
59
|
+
*
|
|
60
|
+
* The PIN layer is authenticated before the host adapter is called. A wrong
|
|
61
|
+
* PIN therefore neither reaches KMS nor becomes indistinguishable from a KMS
|
|
62
|
+
* availability failure in audit logs.
|
|
63
|
+
*/
|
|
64
|
+
export declare function openServerProfileSecret(envelope: PinProtectedProfileSecret, pin: string, aad: string, hostSealer: ServerProfileSealer): Promise<string>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
3
|
+
/** Distinguishes a wrong PIN from a host KMS outage or corrupted payload. */
|
|
4
|
+
export class ProfilePinRejectedError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super('Profile PIN rejected.');
|
|
7
|
+
this.name = 'ProfilePinRejectedError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Encrypt one seed, private-key export or credential using a fresh DEK.
|
|
12
|
+
*
|
|
13
|
+
* The returned salt is intentionally stored in clear. Security comes from the
|
|
14
|
+
* PIN work factor, the independent host KEK and authenticated encryption, not
|
|
15
|
+
* from hiding the salt or algorithm parameters.
|
|
16
|
+
*/
|
|
17
|
+
export async function protectServerProfileSecret(cleartext, pin, aad, hostSealer, options = {}) {
|
|
18
|
+
requirePin(pin);
|
|
19
|
+
const kdf = {
|
|
20
|
+
name: 'scrypt',
|
|
21
|
+
saltBase64Url: randomBytes(16).toString('base64url'),
|
|
22
|
+
cost: options.cost ?? 16384,
|
|
23
|
+
blockSize: options.blockSize ?? 8,
|
|
24
|
+
parallelization: options.parallelization ?? 1,
|
|
25
|
+
keyLength: 32,
|
|
26
|
+
};
|
|
27
|
+
const pinKey = derivePinKey(pin, kdf);
|
|
28
|
+
const dek = randomBytes(32);
|
|
29
|
+
const hostWrappedDek = await hostSealer.seal(dek.toString('base64url'), `${aad}:dek`);
|
|
30
|
+
try {
|
|
31
|
+
return {
|
|
32
|
+
version: 'gdc-pin-host-envelope-v1',
|
|
33
|
+
kdf,
|
|
34
|
+
payload: encryptAesGcm(Buffer.from(cleartext), dek, `${aad}:payload`),
|
|
35
|
+
pinWrappedHostDek: encryptAesGcm(Buffer.from(hostWrappedDek), pinKey, `${aad}:host-wrapped-dek`),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
pinKey.fill(0);
|
|
40
|
+
dek.fill(0);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Open one protected profile secret using both PIN and host protection.
|
|
45
|
+
*
|
|
46
|
+
* The PIN layer is authenticated before the host adapter is called. A wrong
|
|
47
|
+
* PIN therefore neither reaches KMS nor becomes indistinguishable from a KMS
|
|
48
|
+
* availability failure in audit logs.
|
|
49
|
+
*/
|
|
50
|
+
export async function openServerProfileSecret(envelope, pin, aad, hostSealer) {
|
|
51
|
+
validateEnvelope(envelope);
|
|
52
|
+
const pinKey = derivePinKey(pin, envelope.kdf);
|
|
53
|
+
let hostWrappedDek;
|
|
54
|
+
try {
|
|
55
|
+
hostWrappedDek = decryptAesGcm(envelope.pinWrappedHostDek, pinKey, `${aad}:host-wrapped-dek`).toString('utf8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new ProfilePinRejectedError();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
pinKey.fill(0);
|
|
62
|
+
}
|
|
63
|
+
const dek = Buffer.from(await hostSealer.unseal(hostWrappedDek, `${aad}:dek`), 'base64url');
|
|
64
|
+
try {
|
|
65
|
+
if (dek.length !== 32)
|
|
66
|
+
throw new Error('Profile host returned an invalid DEK.');
|
|
67
|
+
return decryptAesGcm(envelope.payload, dek, `${aad}:payload`).toString('utf8');
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
dek.fill(0);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function derivePinKey(pin, parameters) {
|
|
74
|
+
requirePin(pin);
|
|
75
|
+
return scryptSync(pin, Buffer.from(parameters.saltBase64Url, 'base64url'), parameters.keyLength, {
|
|
76
|
+
N: parameters.cost,
|
|
77
|
+
r: parameters.blockSize,
|
|
78
|
+
p: parameters.parallelization,
|
|
79
|
+
maxmem: Math.max(64 * 1024 * 1024, 256 * parameters.cost * parameters.blockSize),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function encryptAesGcm(cleartext, key, aad) {
|
|
83
|
+
const iv = randomBytes(12);
|
|
84
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
85
|
+
cipher.setAAD(Buffer.from(aad));
|
|
86
|
+
const ciphertext = Buffer.concat([cipher.update(cleartext), cipher.final()]);
|
|
87
|
+
return {
|
|
88
|
+
ivBase64Url: iv.toString('base64url'),
|
|
89
|
+
ciphertextBase64Url: ciphertext.toString('base64url'),
|
|
90
|
+
tagBase64Url: cipher.getAuthTag().toString('base64url'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function decryptAesGcm(value, key, aad) {
|
|
94
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(value.ivBase64Url, 'base64url'));
|
|
95
|
+
decipher.setAAD(Buffer.from(aad));
|
|
96
|
+
decipher.setAuthTag(Buffer.from(value.tagBase64Url, 'base64url'));
|
|
97
|
+
return Buffer.concat([
|
|
98
|
+
decipher.update(Buffer.from(value.ciphertextBase64Url, 'base64url')),
|
|
99
|
+
decipher.final(),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
function requirePin(pin) {
|
|
103
|
+
if (String(pin).length < 6)
|
|
104
|
+
throw new Error('Production profile PIN must contain at least 6 characters.');
|
|
105
|
+
}
|
|
106
|
+
function validateEnvelope(value) {
|
|
107
|
+
if (value?.version !== 'gdc-pin-host-envelope-v1' || value.kdf?.name !== 'scrypt') {
|
|
108
|
+
throw new Error('Unsupported protected profile envelope.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { JWK } from 'gdc-common-utils-ts/models/jwk';
|
|
2
|
+
import type { ActorKind } from 'gdc-common-utils-ts/models/actor-session';
|
|
3
|
+
import type { RouteContext } from './individual-onboarding.js';
|
|
4
|
+
import type { SecureDidcommTransportAdapter } from 'gdc-sdk-core-ts';
|
|
5
|
+
import { type PinProtectedProfileSecret, type ProfileProtectionOptions, type ServerProfileSealer } from './server-profile-protection.js';
|
|
6
|
+
export type { ServerProfileSealer } from './server-profile-protection.js';
|
|
7
|
+
export type ServerActorMode = 'self' | 'controller' | 'member';
|
|
8
|
+
/** Durable public metadata plus PIN-and-host protected private material. */
|
|
9
|
+
export type ServerProfileRecord = Readonly<{
|
|
10
|
+
profileId: string;
|
|
11
|
+
ownerId: string;
|
|
12
|
+
actorKind: ActorKind;
|
|
13
|
+
actorMode: ServerActorMode;
|
|
14
|
+
actorDid: string;
|
|
15
|
+
profileDid: string;
|
|
16
|
+
providerDid: string;
|
|
17
|
+
routeContext: RouteContext;
|
|
18
|
+
allowedSubjectDids: string[];
|
|
19
|
+
clientId: string;
|
|
20
|
+
deviceDid: string;
|
|
21
|
+
publicJwks: Record<string, unknown>[];
|
|
22
|
+
protectedWalletSeed: PinProtectedProfileSecret;
|
|
23
|
+
protectedVpToken: PinProtectedProfileSecret;
|
|
24
|
+
failedUnlocks: number;
|
|
25
|
+
lockedUntil?: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
}>;
|
|
29
|
+
export type ServerProfileSessionRecord = Readonly<{
|
|
30
|
+
sessionId: string;
|
|
31
|
+
ownerId: string;
|
|
32
|
+
profileId: string;
|
|
33
|
+
subjectDid: string;
|
|
34
|
+
scopes: string[];
|
|
35
|
+
sealedUnlockedWalletSeed: string;
|
|
36
|
+
sealedAccessToken: string;
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
}>;
|
|
39
|
+
/** Persistence port; implementations must isolate environment and tenant data. */
|
|
40
|
+
export type ServerProfileStore = Readonly<{
|
|
41
|
+
listProfiles(ownerId: string): Promise<ServerProfileRecord[]>;
|
|
42
|
+
getProfile(profileId: string): Promise<ServerProfileRecord | undefined>;
|
|
43
|
+
putProfile(profile: ServerProfileRecord): Promise<void>;
|
|
44
|
+
getSession(sessionId: string): Promise<ServerProfileSessionRecord | undefined>;
|
|
45
|
+
putSession(session: ServerProfileSessionRecord): Promise<void>;
|
|
46
|
+
deleteSession(sessionId: string): Promise<void>;
|
|
47
|
+
}>;
|
|
48
|
+
export type ServerProfileEnrollmentInput = Readonly<{
|
|
49
|
+
ownerId: string;
|
|
50
|
+
profileId: string;
|
|
51
|
+
actorKind: ActorKind;
|
|
52
|
+
actorMode: ServerActorMode;
|
|
53
|
+
actorDid: string;
|
|
54
|
+
profileDid: string;
|
|
55
|
+
providerDid: string;
|
|
56
|
+
routeContext: RouteContext;
|
|
57
|
+
allowedSubjectDids: string[];
|
|
58
|
+
pin: string;
|
|
59
|
+
idToken: string;
|
|
60
|
+
activationCode: string;
|
|
61
|
+
vpToken: string;
|
|
62
|
+
}>;
|
|
63
|
+
/** One explicit unlock request; scopes and subject remain session-bound. */
|
|
64
|
+
export type ServerProfileUnlockInput = Readonly<{
|
|
65
|
+
ownerId: string;
|
|
66
|
+
profileId: string;
|
|
67
|
+
subjectDid: string;
|
|
68
|
+
scopes: string[];
|
|
69
|
+
pin: string;
|
|
70
|
+
idToken: string;
|
|
71
|
+
}>;
|
|
72
|
+
/** Material available only during an authenticated, unexpired server session. */
|
|
73
|
+
export type ResolvedServerProfileSession = Readonly<{
|
|
74
|
+
sessionId: string;
|
|
75
|
+
profile: ServerProfileRecord;
|
|
76
|
+
subjectDid: string;
|
|
77
|
+
scopes: string[];
|
|
78
|
+
accessToken: string;
|
|
79
|
+
secureTransportAdapter: SecureDidcommTransportAdapter;
|
|
80
|
+
}>;
|
|
81
|
+
export type ServerProfileSessionManagerOptions = Readonly<{
|
|
82
|
+
store: ServerProfileStore;
|
|
83
|
+
sealer: ServerProfileSealer;
|
|
84
|
+
gatewayBaseUrl: string;
|
|
85
|
+
recipientDid: string;
|
|
86
|
+
resolveRecipientJwk: (recipientDid: string) => Promise<JWK>;
|
|
87
|
+
fetchImpl?: typeof fetch;
|
|
88
|
+
sessionTtlSeconds?: number;
|
|
89
|
+
maxFailedUnlocks?: number;
|
|
90
|
+
lockSeconds?: number;
|
|
91
|
+
profileProtection?: ProfileProtectionOptions;
|
|
92
|
+
now?: () => Date;
|
|
93
|
+
}>;
|
|
94
|
+
/**
|
|
95
|
+
* Coordinates device registration, two-factor profile protection and SMART sessions.
|
|
96
|
+
*
|
|
97
|
+
* Persisted seeds require both the PIN-derived key and the host KEK. After a
|
|
98
|
+
* successful unlock, a short-lived host-sealed seed is copied into the server
|
|
99
|
+
* session so subsequent requests do not need to resend the PIN. Expiring or
|
|
100
|
+
* locking the session removes that temporary bypass.
|
|
101
|
+
*/
|
|
102
|
+
export declare class ServerProfileSessionManager {
|
|
103
|
+
private readonly options;
|
|
104
|
+
constructor(options: ServerProfileSessionManagerOptions);
|
|
105
|
+
enroll(input: ServerProfileEnrollmentInput): Promise<ServerProfileRecord>;
|
|
106
|
+
listProfiles(ownerId: string): Promise<ServerProfileRecord[]>;
|
|
107
|
+
unlock(input: ServerProfileUnlockInput): Promise<ResolvedServerProfileSession>;
|
|
108
|
+
resolveSession(ownerId: string, sessionId: string): Promise<ResolvedServerProfileSession>;
|
|
109
|
+
lock(ownerId: string, sessionId: string): Promise<void>;
|
|
110
|
+
private createClient;
|
|
111
|
+
private createWallet;
|
|
112
|
+
private requireOwnedProfile;
|
|
113
|
+
private requireSubject;
|
|
114
|
+
private now;
|
|
115
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { NodeManagedWallet } from './node-managed-wallet.js';
|
|
4
|
+
import { NodeHttpClient } from './node-runtime-client.js';
|
|
5
|
+
import { ProfilePinRejectedError, openServerProfileSecret, protectServerProfileSecret, } from './server-profile-protection.js';
|
|
6
|
+
/**
|
|
7
|
+
* Coordinates device registration, two-factor profile protection and SMART sessions.
|
|
8
|
+
*
|
|
9
|
+
* Persisted seeds require both the PIN-derived key and the host KEK. After a
|
|
10
|
+
* successful unlock, a short-lived host-sealed seed is copied into the server
|
|
11
|
+
* session so subsequent requests do not need to resend the PIN. Expiring or
|
|
12
|
+
* locking the session removes that temporary bypass.
|
|
13
|
+
*/
|
|
14
|
+
export class ServerProfileSessionManager {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async enroll(input) {
|
|
19
|
+
requireEnrollment(input);
|
|
20
|
+
const seed = randomBytes(32).toString('base64url');
|
|
21
|
+
const wallet = await this.createWallet(input.profileId, seed);
|
|
22
|
+
const context = walletContext(input.profileId);
|
|
23
|
+
const publicKeys = await wallet.getPublicJwks(context, {});
|
|
24
|
+
const client = this.createClient(input.routeContext, input.idToken);
|
|
25
|
+
const activation = await client.activateProfileDeviceWithActivationRequest({
|
|
26
|
+
...input.routeContext,
|
|
27
|
+
activationCode: input.activationCode,
|
|
28
|
+
idToken: input.idToken,
|
|
29
|
+
dcrPayload: {
|
|
30
|
+
application_type: 'web',
|
|
31
|
+
actor_did: input.actorDid,
|
|
32
|
+
profile_did: input.profileDid,
|
|
33
|
+
jwks: { keys: publicKeys.map((entry) => entry.publicJwk) },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const dcrBody = terminalBody(activation.dcr.poll.body);
|
|
37
|
+
const clientId = findText(dcrBody, ['client_id', 'clientId']);
|
|
38
|
+
if (!clientId)
|
|
39
|
+
throw new Error('GW DCR did not return client_id.');
|
|
40
|
+
const deviceDid = findText(dcrBody, ['device_did', 'deviceDid', 'did']) || clientId;
|
|
41
|
+
const now = this.now();
|
|
42
|
+
const record = {
|
|
43
|
+
profileId: input.profileId,
|
|
44
|
+
ownerId: input.ownerId,
|
|
45
|
+
actorKind: input.actorKind,
|
|
46
|
+
actorMode: input.actorMode,
|
|
47
|
+
actorDid: input.actorDid,
|
|
48
|
+
profileDid: input.profileDid,
|
|
49
|
+
providerDid: input.providerDid,
|
|
50
|
+
routeContext: input.routeContext,
|
|
51
|
+
allowedSubjectDids: unique(input.allowedSubjectDids),
|
|
52
|
+
clientId,
|
|
53
|
+
deviceDid,
|
|
54
|
+
publicJwks: publicKeys.map((entry) => entry.publicJwk),
|
|
55
|
+
protectedWalletSeed: await protectServerProfileSecret(seed, input.pin, `${input.profileId}:wallet-seed`, this.options.sealer, this.options.profileProtection),
|
|
56
|
+
protectedVpToken: await protectServerProfileSecret(input.vpToken, input.pin, `${input.profileId}:vp-token`, this.options.sealer, this.options.profileProtection),
|
|
57
|
+
failedUnlocks: 0,
|
|
58
|
+
createdAt: now.toISOString(),
|
|
59
|
+
updatedAt: now.toISOString(),
|
|
60
|
+
};
|
|
61
|
+
await this.options.store.putProfile(record);
|
|
62
|
+
return record;
|
|
63
|
+
}
|
|
64
|
+
listProfiles(ownerId) {
|
|
65
|
+
return this.options.store.listProfiles(ownerId);
|
|
66
|
+
}
|
|
67
|
+
async unlock(input) {
|
|
68
|
+
const profile = await this.requireOwnedProfile(input.ownerId, input.profileId);
|
|
69
|
+
this.requireSubject(profile, input.subjectDid);
|
|
70
|
+
const now = this.now();
|
|
71
|
+
if (profile.lockedUntil && new Date(profile.lockedUntil) > now) {
|
|
72
|
+
throw new Error('Profile is temporarily locked after failed PIN attempts.');
|
|
73
|
+
}
|
|
74
|
+
let seed;
|
|
75
|
+
let vpToken;
|
|
76
|
+
try {
|
|
77
|
+
seed = await openServerProfileSecret(profile.protectedWalletSeed, input.pin, `${profile.profileId}:wallet-seed`, this.options.sealer);
|
|
78
|
+
vpToken = await openServerProfileSecret(profile.protectedVpToken, input.pin, `${profile.profileId}:vp-token`, this.options.sealer);
|
|
79
|
+
}
|
|
80
|
+
catch (reason) {
|
|
81
|
+
if (!(reason instanceof ProfilePinRejectedError))
|
|
82
|
+
throw reason;
|
|
83
|
+
const failures = profile.failedUnlocks + 1;
|
|
84
|
+
const max = this.options.maxFailedUnlocks ?? 5;
|
|
85
|
+
const lockedUntil = failures >= max
|
|
86
|
+
? new Date(now.getTime() + (this.options.lockSeconds ?? 300) * 1000).toISOString()
|
|
87
|
+
: undefined;
|
|
88
|
+
await this.options.store.putProfile({ ...profile, failedUnlocks: failures, lockedUntil, updatedAt: now.toISOString() });
|
|
89
|
+
throw new Error('Profile PIN rejected.');
|
|
90
|
+
}
|
|
91
|
+
const wallet = await this.createWallet(profile.profileId, seed);
|
|
92
|
+
const assertion = await buildWalletClientAssertion(wallet, profile, this.options.gatewayBaseUrl, now);
|
|
93
|
+
const token = await this.createClient(profile.routeContext, input.idToken).requestSmartToken({
|
|
94
|
+
...profile.routeContext,
|
|
95
|
+
actorDid: profile.actorDid,
|
|
96
|
+
subjectDid: input.subjectDid,
|
|
97
|
+
clientId: profile.clientId,
|
|
98
|
+
issuer: profile.clientId,
|
|
99
|
+
audience: this.options.gatewayBaseUrl,
|
|
100
|
+
idToken: input.idToken,
|
|
101
|
+
vpToken,
|
|
102
|
+
clientAssertion: assertion,
|
|
103
|
+
clientAssertionType: 'private_key_jwt',
|
|
104
|
+
smartTokenKind: 'openid-smart',
|
|
105
|
+
scopes: unique(input.scopes),
|
|
106
|
+
tokenCacheKey: `profile:${profile.profileId}:${input.subjectDid}:${unique(input.scopes).join(',')}`,
|
|
107
|
+
});
|
|
108
|
+
if (token.status !== 'fetched' || !token.accessToken)
|
|
109
|
+
throw new Error('SMART token exchange failed.');
|
|
110
|
+
const sessionId = randomBytes(32).toString('base64url');
|
|
111
|
+
const expiresAt = new Date(now.getTime() + (this.options.sessionTtlSeconds ?? 300) * 1000);
|
|
112
|
+
await this.options.store.putProfile({ ...profile, failedUnlocks: 0, lockedUntil: undefined, updatedAt: now.toISOString() });
|
|
113
|
+
await this.options.store.putSession({
|
|
114
|
+
sessionId,
|
|
115
|
+
ownerId: input.ownerId,
|
|
116
|
+
profileId: profile.profileId,
|
|
117
|
+
subjectDid: input.subjectDid,
|
|
118
|
+
scopes: unique(input.scopes),
|
|
119
|
+
sealedUnlockedWalletSeed: await this.options.sealer.seal(seed, `${sessionId}:unlocked-wallet-seed`),
|
|
120
|
+
sealedAccessToken: await this.options.sealer.seal(token.accessToken, `${sessionId}:access-token`),
|
|
121
|
+
expiresAt: expiresAt.toISOString(),
|
|
122
|
+
});
|
|
123
|
+
return this.resolveSession(input.ownerId, sessionId);
|
|
124
|
+
}
|
|
125
|
+
async resolveSession(ownerId, sessionId) {
|
|
126
|
+
const session = await this.options.store.getSession(sessionId);
|
|
127
|
+
if (!session || session.ownerId !== ownerId)
|
|
128
|
+
throw new Error('Profile session not found.');
|
|
129
|
+
if (new Date(session.expiresAt) <= this.now()) {
|
|
130
|
+
await this.options.store.deleteSession(sessionId);
|
|
131
|
+
throw new Error('Profile session expired.');
|
|
132
|
+
}
|
|
133
|
+
const profile = await this.requireOwnedProfile(ownerId, session.profileId);
|
|
134
|
+
const seed = await this.options.sealer.unseal(session.sealedUnlockedWalletSeed, `${sessionId}:unlocked-wallet-seed`);
|
|
135
|
+
const wallet = await this.createWallet(profile.profileId, seed);
|
|
136
|
+
const context = walletContext(profile.profileId);
|
|
137
|
+
return {
|
|
138
|
+
sessionId,
|
|
139
|
+
profile,
|
|
140
|
+
subjectDid: session.subjectDid,
|
|
141
|
+
scopes: session.scopes,
|
|
142
|
+
accessToken: await this.options.sealer.unseal(session.sealedAccessToken, `${sessionId}:access-token`),
|
|
143
|
+
secureTransportAdapter: {
|
|
144
|
+
pack: (message) => wallet.packForRecipientWithContext(message, this.options.recipientDid, { context }),
|
|
145
|
+
unpack: async (jwe) => (await wallet.unpackWithContext(jwe, { context })).content,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async lock(ownerId, sessionId) {
|
|
150
|
+
const session = await this.options.store.getSession(sessionId);
|
|
151
|
+
if (session?.ownerId === ownerId)
|
|
152
|
+
await this.options.store.deleteSession(sessionId);
|
|
153
|
+
}
|
|
154
|
+
createClient(ctx, bearerToken) {
|
|
155
|
+
return new NodeHttpClient({
|
|
156
|
+
baseUrl: this.options.gatewayBaseUrl,
|
|
157
|
+
ctx,
|
|
158
|
+
bearerToken,
|
|
159
|
+
fetchImpl: this.options.fetchImpl,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async createWallet(profileId, seed) {
|
|
163
|
+
const wallet = new NodeManagedWallet({ resolveRecipientJwk: this.options.resolveRecipientJwk });
|
|
164
|
+
const context = walletContext(profileId);
|
|
165
|
+
await wallet.provisionManagedKeys(context, {
|
|
166
|
+
ownerScope: 'profile',
|
|
167
|
+
purposes: ['actor-signing'],
|
|
168
|
+
mode: 'deterministic',
|
|
169
|
+
seedMaterial: seed,
|
|
170
|
+
});
|
|
171
|
+
await wallet.provisionManagedKeys(context, {
|
|
172
|
+
ownerScope: 'runtime',
|
|
173
|
+
purposes: ['openid-id-token-signing', 'vp-token-signing', 'comm-signing', 'comm-encryption'],
|
|
174
|
+
mode: 'deterministic',
|
|
175
|
+
seedMaterial: seed,
|
|
176
|
+
});
|
|
177
|
+
return wallet;
|
|
178
|
+
}
|
|
179
|
+
async requireOwnedProfile(ownerId, profileId) {
|
|
180
|
+
const profile = await this.options.store.getProfile(profileId);
|
|
181
|
+
if (!profile || profile.ownerId !== ownerId)
|
|
182
|
+
throw new Error('Profile not found.');
|
|
183
|
+
return profile;
|
|
184
|
+
}
|
|
185
|
+
requireSubject(profile, subjectDid) {
|
|
186
|
+
if (!profile.allowedSubjectDids.includes(subjectDid))
|
|
187
|
+
throw new Error('Subject is not linked to this profile.');
|
|
188
|
+
}
|
|
189
|
+
now() { return this.options.now?.() || new Date(); }
|
|
190
|
+
}
|
|
191
|
+
async function buildWalletClientAssertion(wallet, profile, audience, now) {
|
|
192
|
+
const seconds = Math.floor(now.getTime() / 1000);
|
|
193
|
+
return wallet.signCompactJws(walletContext(profile.profileId), {
|
|
194
|
+
header: { alg: 'ES384', typ: 'JWT' },
|
|
195
|
+
claims: {
|
|
196
|
+
iss: profile.clientId,
|
|
197
|
+
sub: profile.clientId,
|
|
198
|
+
aud: audience,
|
|
199
|
+
iat: seconds,
|
|
200
|
+
exp: seconds + 300,
|
|
201
|
+
jti: randomBytes(16).toString('base64url'),
|
|
202
|
+
},
|
|
203
|
+
key: { ownerScope: 'runtime', purpose: 'openid-id-token-signing' },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function walletContext(profileId) {
|
|
207
|
+
return {
|
|
208
|
+
profile: { profileId },
|
|
209
|
+
runtime: { runtimeId: `${profileId}:server-runtime`, runtimeType: 'backend-service' },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function requireEnrollment(input) {
|
|
213
|
+
for (const [name, value] of Object.entries({
|
|
214
|
+
ownerId: input.ownerId,
|
|
215
|
+
profileId: input.profileId,
|
|
216
|
+
actorDid: input.actorDid,
|
|
217
|
+
profileDid: input.profileDid,
|
|
218
|
+
providerDid: input.providerDid,
|
|
219
|
+
activationCode: input.activationCode,
|
|
220
|
+
vpToken: input.vpToken,
|
|
221
|
+
}))
|
|
222
|
+
if (!String(value || '').trim())
|
|
223
|
+
throw new Error(`Profile enrollment requires ${name}.`);
|
|
224
|
+
if (!input.allowedSubjectDids.length)
|
|
225
|
+
throw new Error('Profile enrollment requires an allowed subject.');
|
|
226
|
+
}
|
|
227
|
+
function unique(values) {
|
|
228
|
+
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort();
|
|
229
|
+
}
|
|
230
|
+
function terminalBody(value) {
|
|
231
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && 'body' in value) {
|
|
232
|
+
return value.body || value;
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
function findText(value, keys) {
|
|
237
|
+
if (!value || typeof value !== 'object')
|
|
238
|
+
return undefined;
|
|
239
|
+
if (Array.isArray(value)) {
|
|
240
|
+
for (const item of value) {
|
|
241
|
+
const found = findText(item, keys);
|
|
242
|
+
if (found)
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const record = value;
|
|
248
|
+
for (const key of keys) {
|
|
249
|
+
const found = String(record[key] || '').trim();
|
|
250
|
+
if (found)
|
|
251
|
+
return found;
|
|
252
|
+
}
|
|
253
|
+
for (const child of Object.values(record)) {
|
|
254
|
+
const found = findText(child, keys);
|
|
255
|
+
if (found)
|
|
256
|
+
return found;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|