@learncard/lca-api-service 1.3.0 → 1.3.2
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/CHANGELOG.md +55 -0
- package/dist/app.d.ts +8 -2
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/recipient-encryption.helpers.d.ts +4 -0
- package/dist/helpers/recipient-encryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Notification.d.ts +6 -0
- package/dist/routes/credentials.d.ts +2 -2
- package/dist/routes/firebase.test.d.ts +1 -0
- package/dist/routes/notifications.d.ts +6 -0
- package/dist/types/notifications.d.ts +6 -0
- package/package.json +11 -9
- package/src/helpers/recipient-encryption.helpers.test.ts +129 -0
- package/src/helpers/recipient-encryption.helpers.ts +91 -0
- package/src/routes/credentials.ts +26 -1
- package/src/routes/firebase.test.ts +94 -0
- package/src/routes/firebase.ts +9 -8
- package/test/credentials.spec.ts +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# @welibraryos/lca-api-service
|
|
2
2
|
|
|
3
|
+
## 1.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#1588](https://github.com/learningeconomy/LearnCard/pull/1588) [`02ed52ec7d86b51cc45de29b4f741e596e8d7c0a`](https://github.com/learningeconomy/LearnCard/commit/02ed52ec7d86b51cc45de29b4f741e596e8d7c0a) Thanks [@goblincore](https://github.com/goblincore)! - Fix automatic email verification in local dev stacks
|
|
8
|
+
|
|
9
|
+
- Updated dependencies [[`2991bd32b03e26736239dd8e586e2f720d9bcd45`](https://github.com/learningeconomy/LearnCard/commit/2991bd32b03e26736239dd8e586e2f720d9bcd45), [`0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e`](https://github.com/learningeconomy/LearnCard/commit/0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e), [`0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e`](https://github.com/learningeconomy/LearnCard/commit/0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e), [`3174c09b97fde6f7b5251390245038cf6b7ea9ba`](https://github.com/learningeconomy/LearnCard/commit/3174c09b97fde6f7b5251390245038cf6b7ea9ba), [`928e587378b3674766cf58a8bbe1cbd4d66d3a9f`](https://github.com/learningeconomy/LearnCard/commit/928e587378b3674766cf58a8bbe1cbd4d66d3a9f), [`928e587378b3674766cf58a8bbe1cbd4d66d3a9f`](https://github.com/learningeconomy/LearnCard/commit/928e587378b3674766cf58a8bbe1cbd4d66d3a9f)]:
|
|
10
|
+
- @learncard/types@5.21.0
|
|
11
|
+
- @learncard/init@2.5.0
|
|
12
|
+
- @learncard/helpers@1.6.0
|
|
13
|
+
- @learncard/email-templates@1.1.0
|
|
14
|
+
- @learncard/core@9.4.36
|
|
15
|
+
- @learncard/did-web-plugin@1.1.36
|
|
16
|
+
- @learncard/didkit-plugin@1.10.1
|
|
17
|
+
- @learncard/didkit-plugin-node@0.3.1
|
|
18
|
+
|
|
19
|
+
## 1.3.1
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- [#1566](https://github.com/learningeconomy/LearnCard/pull/1566) [`e46c302eaed8b98686d21e2f3b5d11b189567304`](https://github.com/learningeconomy/LearnCard/commit/e46c302eaed8b98686d21e2f3b5d11b189567304) Thanks [@gerardopar](https://github.com/gerardopar)! - feat: [LC-2155] - Eliminate CertifiedBoostCredential wrapper
|
|
24
|
+
|
|
25
|
+
Direct credentials use their signed Boost network URI for the trusted-network check.
|
|
26
|
+
Plaintext sends reject mismatched Boost IDs. Encrypted signing-authority issuance
|
|
27
|
+
includes all subjects and, for delegated consent AutoBoosts, the contract owner.
|
|
28
|
+
|
|
29
|
+
Compatibility limitation: pre-signed `signedCredential` payloads (both plaintext and
|
|
30
|
+
client-encrypted) are stored unchanged, without a server-generated wrapper or status
|
|
31
|
+
entries. This includes plaintext credentials from older SDKs or third-party issuers
|
|
32
|
+
that omit `credentialStatus`. Without embedded status entries or server-retained status
|
|
33
|
+
coordinates, network revocation changes the recipient relationship only and is not
|
|
34
|
+
reflected by a holder's `verifyCredential` call. Such integrations must publish their
|
|
35
|
+
own signed status-list updates or use server-managed signing-authority issuance with
|
|
36
|
+
a VC v2 template. Storage now warns for both plaintext and encrypted credentials when
|
|
37
|
+
status metadata is missing or empty, including VC v1 issuance results.
|
|
38
|
+
|
|
39
|
+
- [#1566](https://github.com/learningeconomy/LearnCard/pull/1566) [`e46c302eaed8b98686d21e2f3b5d11b189567304`](https://github.com/learningeconomy/LearnCard/commit/e46c302eaed8b98686d21e2f3b5d11b189567304) Thanks [@gerardopar](https://github.com/gerardopar)! - Encrypt signing-authority credentials using a single snapshot of each recipient's
|
|
40
|
+
X25519 keys. Keep DAG-JWE compatibility while eliminating repeated DID resolution
|
|
41
|
+
and post-encryption key-ID matching.
|
|
42
|
+
|
|
43
|
+
Carry status entries explicitly in serializable internal issuance results so copied
|
|
44
|
+
or cached credentials retain revocation metadata. Reject missing internal metadata,
|
|
45
|
+
and return false when persisted status JSON is malformed or fails validation.
|
|
46
|
+
|
|
47
|
+
Report an explicit Boost-authenticity warning when credential verification fails.
|
|
48
|
+
|
|
49
|
+
- Updated dependencies [[`20b3844ddb7e649c9964308214ec4c395e9fc8db`](https://github.com/learningeconomy/LearnCard/commit/20b3844ddb7e649c9964308214ec4c395e9fc8db), [`b4f94f5a5ffbd52bad6cd26dc3dd627df8d5e6fb`](https://github.com/learningeconomy/LearnCard/commit/b4f94f5a5ffbd52bad6cd26dc3dd627df8d5e6fb), [`19bb79b1355dd9de7f71554fdb608f38b78ed6bb`](https://github.com/learningeconomy/LearnCard/commit/19bb79b1355dd9de7f71554fdb608f38b78ed6bb)]:
|
|
50
|
+
- @learncard/didkit-plugin@1.10.0
|
|
51
|
+
- @learncard/didkit-plugin-node@0.3.0
|
|
52
|
+
- @learncard/types@5.20.0
|
|
53
|
+
- @learncard/init@2.4.16
|
|
54
|
+
- @learncard/core@9.4.35
|
|
55
|
+
- @learncard/helpers@1.5.1
|
|
56
|
+
- @learncard/did-web-plugin@1.1.35
|
|
57
|
+
|
|
3
58
|
## 1.3.0
|
|
4
59
|
|
|
5
60
|
### Minor Changes
|
package/dist/app.d.ts
CHANGED
|
@@ -208,6 +208,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
208
208
|
} | undefined;
|
|
209
209
|
deniedWriters?: string[] | undefined;
|
|
210
210
|
} | undefined;
|
|
211
|
+
guardianApproval?: {
|
|
212
|
+
guardianProfileId: string;
|
|
213
|
+
guardianDid: string;
|
|
214
|
+
approvedAt: string;
|
|
215
|
+
contractUpdatedAt: string;
|
|
216
|
+
} | undefined;
|
|
211
217
|
uris?: string[] | undefined;
|
|
212
218
|
} | undefined;
|
|
213
219
|
inbox?: {
|
|
@@ -553,9 +559,9 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
553
559
|
proof: {
|
|
554
560
|
[x: string]: any;
|
|
555
561
|
type: string;
|
|
556
|
-
created: string;
|
|
557
562
|
proofPurpose: string;
|
|
558
563
|
verificationMethod: string;
|
|
564
|
+
created?: string | undefined;
|
|
559
565
|
challenge?: string | undefined;
|
|
560
566
|
domain?: string | undefined;
|
|
561
567
|
nonce?: string | undefined;
|
|
@@ -563,9 +569,9 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
563
569
|
} | {
|
|
564
570
|
[x: string]: any;
|
|
565
571
|
type: string;
|
|
566
|
-
created: string;
|
|
567
572
|
proofPurpose: string;
|
|
568
573
|
verificationMethod: string;
|
|
574
|
+
created?: string | undefined;
|
|
569
575
|
challenge?: string | undefined;
|
|
570
576
|
domain?: string | undefined;
|
|
571
577
|
nonce?: string | undefined;
|
package/dist/didkit_wasm_bg.wasm
CHANGED
|
Binary file
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type DidDocument, type JWE, type VC } from '@learncard/types';
|
|
2
|
+
import { type Encrypter } from 'did-jwt';
|
|
3
|
+
export declare const resolveRecipientEncrypters: (recipients: string[], resolveDid: (did: string) => Promise<DidDocument>) => Promise<Encrypter[]>;
|
|
4
|
+
export declare const encryptCredentialForRecipients: (credential: VC, encrypters: Encrypter[]) => Promise<JWE>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/dist/index.js
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 120281915 bytes
|
|
4
|
+
sha256: 5ced11b8e911cdb7296d5c41f76d7e67fee6096c20a98f24ba308818feadfd6e
|
|
@@ -161,6 +161,12 @@ export declare const MongoNotificationValidator: z.ZodObject<{
|
|
|
161
161
|
}, z.core.$strip>>;
|
|
162
162
|
deniedWriters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
163
163
|
}, z.core.$strip>>;
|
|
164
|
+
guardianApproval: z.ZodOptional<z.ZodObject<{
|
|
165
|
+
guardianProfileId: z.ZodString;
|
|
166
|
+
guardianDid: z.ZodString;
|
|
167
|
+
approvedAt: z.ZodString;
|
|
168
|
+
contractUpdatedAt: z.ZodString;
|
|
169
|
+
}, z.core.$strip>>;
|
|
164
170
|
id: z.ZodString;
|
|
165
171
|
action: z.ZodEnum<{
|
|
166
172
|
write: "write";
|
|
@@ -229,9 +229,9 @@ export declare const credentialsRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
229
229
|
proof: {
|
|
230
230
|
[x: string]: any;
|
|
231
231
|
type: string;
|
|
232
|
-
created: string;
|
|
233
232
|
proofPurpose: string;
|
|
234
233
|
verificationMethod: string;
|
|
234
|
+
created?: string | undefined;
|
|
235
235
|
challenge?: string | undefined;
|
|
236
236
|
domain?: string | undefined;
|
|
237
237
|
nonce?: string | undefined;
|
|
@@ -239,9 +239,9 @@ export declare const credentialsRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
239
239
|
} | {
|
|
240
240
|
[x: string]: any;
|
|
241
241
|
type: string;
|
|
242
|
-
created: string;
|
|
243
242
|
proofPurpose: string;
|
|
244
243
|
verificationMethod: string;
|
|
244
|
+
created?: string | undefined;
|
|
245
245
|
challenge?: string | undefined;
|
|
246
246
|
domain?: string | undefined;
|
|
247
247
|
nonce?: string | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -201,6 +201,12 @@ export declare const notificationsRouter: import("@trpc/server").TRPCBuiltRouter
|
|
|
201
201
|
} | undefined;
|
|
202
202
|
deniedWriters?: string[] | undefined;
|
|
203
203
|
} | undefined;
|
|
204
|
+
guardianApproval?: {
|
|
205
|
+
guardianProfileId: string;
|
|
206
|
+
guardianDid: string;
|
|
207
|
+
approvedAt: string;
|
|
208
|
+
contractUpdatedAt: string;
|
|
209
|
+
} | undefined;
|
|
204
210
|
uris?: string[] | undefined;
|
|
205
211
|
} | undefined;
|
|
206
212
|
inbox?: {
|
|
@@ -231,6 +231,12 @@ export declare const PaginatedNotificationsValidator: z.ZodObject<{
|
|
|
231
231
|
}, z.core.$strip>>;
|
|
232
232
|
deniedWriters: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
233
233
|
}, z.core.$strip>>;
|
|
234
|
+
guardianApproval: z.ZodOptional<z.ZodObject<{
|
|
235
|
+
guardianProfileId: z.ZodString;
|
|
236
|
+
guardianDid: z.ZodString;
|
|
237
|
+
approvedAt: z.ZodString;
|
|
238
|
+
contractUpdatedAt: z.ZodString;
|
|
239
|
+
}, z.core.$strip>>;
|
|
234
240
|
id: z.ZodString;
|
|
235
241
|
action: z.ZodEnum<{
|
|
236
242
|
write: "write";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/lca-api-service",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
@@ -50,14 +50,14 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|
|
52
52
|
"@fastify/static": "^10.1.1",
|
|
53
|
-
"@learncard/core": "9.4.
|
|
54
|
-
"@learncard/email-templates": "1.0
|
|
55
|
-
"@learncard/did-web-plugin": "1.1.
|
|
56
|
-
"@learncard/didkit-plugin": "1.
|
|
57
|
-
"@learncard/didkit-plugin-node": "0.
|
|
58
|
-
"@learncard/init": "2.
|
|
59
|
-
"@learncard/helpers": "1.
|
|
60
|
-
"@learncard/types": "5.
|
|
53
|
+
"@learncard/core": "9.4.36",
|
|
54
|
+
"@learncard/email-templates": "1.1.0",
|
|
55
|
+
"@learncard/did-web-plugin": "1.1.36",
|
|
56
|
+
"@learncard/didkit-plugin": "1.10.1",
|
|
57
|
+
"@learncard/didkit-plugin-node": "0.3.1",
|
|
58
|
+
"@learncard/init": "2.5.0",
|
|
59
|
+
"@learncard/helpers": "1.6.0",
|
|
60
|
+
"@learncard/types": "5.21.0",
|
|
61
61
|
"@sentry/serverless": "^7.61.0",
|
|
62
62
|
"@trpc/server": "11.8.0",
|
|
63
63
|
"@types/jsonwebtoken": "^9.0.8",
|
|
@@ -65,6 +65,8 @@
|
|
|
65
65
|
"@types/node-forge": "^1.3.11",
|
|
66
66
|
"axios": "^0.33.0",
|
|
67
67
|
"dotenv": "^16.0.3",
|
|
68
|
+
"dag-jose-utils": "2.0.0",
|
|
69
|
+
"did-jwt": "5.9.0",
|
|
68
70
|
"express": "^4.18.2",
|
|
69
71
|
"fastify": "^5.8.5",
|
|
70
72
|
"filestack-js": "^3.24.0",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { initLearnCard } from '@learncard/init';
|
|
4
|
+
import { base58btc } from 'multiformats/bases/base58';
|
|
5
|
+
import type { DidDocument, VC, VerificationMethod } from '@learncard/types';
|
|
6
|
+
import {
|
|
7
|
+
encryptCredentialForRecipients,
|
|
8
|
+
resolveRecipientEncrypters,
|
|
9
|
+
} from './recipient-encryption.helpers';
|
|
10
|
+
|
|
11
|
+
type KeyMethod = Exclude<VerificationMethod, string>;
|
|
12
|
+
const didkit = readFileSync(
|
|
13
|
+
require.resolve('@learncard/didkit-plugin/dist/didkit/didkit_wasm_bg.wasm')
|
|
14
|
+
);
|
|
15
|
+
let holder: Awaited<ReturnType<typeof makeCard>>;
|
|
16
|
+
let rotated: Awaited<ReturnType<typeof makeCard>>;
|
|
17
|
+
let holderKey: KeyMethod;
|
|
18
|
+
let rotatedKey: KeyMethod;
|
|
19
|
+
const makeCard = (seed: string) => initLearnCard({ didkit, seed });
|
|
20
|
+
const payload = { name: 'Encrypted credential' } as VC;
|
|
21
|
+
const did = 'did:web:holder.example';
|
|
22
|
+
const documentFor = (key: KeyMethod): DidDocument => ({
|
|
23
|
+
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
24
|
+
id: did,
|
|
25
|
+
keyAgreement: [key],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
holder = await makeCard('a'.repeat(64));
|
|
30
|
+
rotated = await makeCard('b'.repeat(64));
|
|
31
|
+
holderKey = (await holder.invoke.resolveDid(holder.id.did())).keyAgreement![0] as KeyMethod;
|
|
32
|
+
rotatedKey = (await rotated.invoke.resolveDid(rotated.id.did())).keyAgreement![0] as KeyMethod;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('recipient key snapshots', () => {
|
|
36
|
+
it('uses the checked key after rotation and produces a DIDKit-decryptable DAG-JWE', async () => {
|
|
37
|
+
const doc = documentFor({ ...holderKey, id: '#before-rotation' });
|
|
38
|
+
const resolveDid = vi.fn(async () => doc);
|
|
39
|
+
const encrypters = await resolveRecipientEncrypters([did, did], resolveDid);
|
|
40
|
+
doc.keyAgreement = [{ ...rotatedKey, id: '#after-rotation' }];
|
|
41
|
+
const jwe = await encryptCredentialForRecipients(payload, encrypters);
|
|
42
|
+
expect(resolveDid).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(jwe.recipients).toHaveLength(1);
|
|
44
|
+
expect(await holder.invoke.decryptDagJwe(jwe)).toEqual(payload);
|
|
45
|
+
expect(await rotated.invoke.decryptDagJwe(jwe).catch(() => undefined)).toBeFalsy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each(['#key', '/keys/1?version=2#key', 'did:web:holder.example/keys/1?version=2#key'])(
|
|
49
|
+
'supports referenced agreement keys with ID %s without matching generated kids',
|
|
50
|
+
async id => {
|
|
51
|
+
const key = { ...holderKey, id };
|
|
52
|
+
const doc = { ...documentFor(key), keyAgreement: [id], verificationMethod: [key] };
|
|
53
|
+
const keys = await resolveRecipientEncrypters([did], async () => doc);
|
|
54
|
+
const jwe = await encryptCredentialForRecipients(payload, keys);
|
|
55
|
+
expect(await holder.invoke.decryptDagJwe(jwe)).toEqual(payload);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
it.each(['base58', 'jwk'])(
|
|
60
|
+
'supports X25519 %s public keys and includes every recipient',
|
|
61
|
+
async encoding => {
|
|
62
|
+
const bytes = base58btc.decode(holderKey.publicKeyMultibase!).slice(2);
|
|
63
|
+
const key: KeyMethod = {
|
|
64
|
+
id: '#encryption',
|
|
65
|
+
controller: did,
|
|
66
|
+
type: encoding === 'base58' ? 'X25519KeyAgreementKey2019' : 'JsonWebKey2020',
|
|
67
|
+
...(encoding === 'base58'
|
|
68
|
+
? { publicKeyBase58: base58btc.baseEncode(bytes) }
|
|
69
|
+
: {
|
|
70
|
+
publicKeyJwk: {
|
|
71
|
+
kty: 'OKP',
|
|
72
|
+
crv: 'X25519',
|
|
73
|
+
x: Buffer.from(bytes).toString('base64url'),
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
const keys = await resolveRecipientEncrypters([did, rotated.id.did()], async target =>
|
|
78
|
+
target === did ? documentFor(key) : documentFor(rotatedKey)
|
|
79
|
+
);
|
|
80
|
+
const jwe = await encryptCredentialForRecipients(payload, keys);
|
|
81
|
+
expect(jwe.recipients).toHaveLength(2);
|
|
82
|
+
expect(await holder.invoke.decryptDagJwe(jwe)).toEqual(payload);
|
|
83
|
+
expect(await rotated.invoke.decryptDagJwe(jwe)).toEqual(payload);
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
it('rejects missing, unresolved, or unsupported keys before issuance', async () => {
|
|
88
|
+
await expect(
|
|
89
|
+
resolveRecipientEncrypters([did], async () => {
|
|
90
|
+
throw new Error('not found');
|
|
91
|
+
})
|
|
92
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
93
|
+
await expect(
|
|
94
|
+
resolveRecipientEncrypters([did], async () =>
|
|
95
|
+
documentFor({
|
|
96
|
+
id: '#unsupported',
|
|
97
|
+
controller: did,
|
|
98
|
+
type: 'JsonWebKey2020',
|
|
99
|
+
publicKeyJwk: { kty: 'OKP', crv: 'Ed25519', x: 'invalid' },
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('resolves a relative relationship against an absolute verification-method ID', async () => {
|
|
106
|
+
const key = { ...holderKey, id: `${did}/keys/1?version=2#key` };
|
|
107
|
+
const doc = {
|
|
108
|
+
...documentFor(key),
|
|
109
|
+
keyAgreement: ['/keys/1?version=2#key'],
|
|
110
|
+
verificationMethod: [key],
|
|
111
|
+
};
|
|
112
|
+
const encrypters = await resolveRecipientEncrypters([did], async () => doc);
|
|
113
|
+
expect(
|
|
114
|
+
await holder.invoke.decryptDagJwe(
|
|
115
|
+
await encryptCredentialForRecipients(payload, encrypters)
|
|
116
|
+
)
|
|
117
|
+
).toEqual(payload);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects a partial encryption result if an encrypter omits its recipient', async () => {
|
|
121
|
+
const encrypters = await resolveRecipientEncrypters([did, rotated.id.did()], async target =>
|
|
122
|
+
documentFor(target === did ? holderKey : rotatedKey)
|
|
123
|
+
);
|
|
124
|
+
encrypters[1] = { ...encrypters[1]!, encryptCek: undefined };
|
|
125
|
+
await expect(encryptCredentialForRecipients(payload, encrypters)).rejects.toThrow(
|
|
126
|
+
'Encryption did not cover every recipient key'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import {
|
|
3
|
+
JWEValidator,
|
|
4
|
+
type DidDocument,
|
|
5
|
+
type JWE,
|
|
6
|
+
type VC,
|
|
7
|
+
type VerificationMethod,
|
|
8
|
+
} from '@learncard/types';
|
|
9
|
+
import { base58btc } from 'multiformats/bases/base58';
|
|
10
|
+
import { createJWE, x25519Encrypter, type Encrypter } from 'did-jwt';
|
|
11
|
+
import { prepareCleartext } from 'dag-jose-utils';
|
|
12
|
+
|
|
13
|
+
type KeyMethod = Exclude<VerificationMethod, string>;
|
|
14
|
+
|
|
15
|
+
const absoluteKeyId = (id: string, did: string): string => {
|
|
16
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(id)) return id;
|
|
17
|
+
if (id.startsWith('#') || id.startsWith('?')) return `${did}${id}`;
|
|
18
|
+
const path = new URL(id, 'https://did.invalid/');
|
|
19
|
+
return `${did}${path.pathname}${path.search}${path.hash}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getX25519Bytes = (method: KeyMethod): Uint8Array | undefined => {
|
|
23
|
+
try {
|
|
24
|
+
if (method.publicKeyJwk) {
|
|
25
|
+
const jwk = method.publicKeyJwk;
|
|
26
|
+
if (jwk.kty !== 'OKP' || jwk.crv !== 'X25519' || typeof jwk.x !== 'string') return;
|
|
27
|
+
if (!/^[A-Za-z0-9_-]+$/.test(jwk.x)) return;
|
|
28
|
+
const bytes = Buffer.from(jwk.x, 'base64url');
|
|
29
|
+
if (bytes.length === 32) return Uint8Array.from(bytes);
|
|
30
|
+
} else if (method.publicKeyMultibase) {
|
|
31
|
+
const bytes = base58btc.decode(method.publicKeyMultibase);
|
|
32
|
+
if (bytes.length === 34 && bytes[0] === 0xec && bytes[1] === 0x01)
|
|
33
|
+
return bytes.slice(2);
|
|
34
|
+
} else if (method.publicKeyBase58 && method.type === 'X25519KeyAgreementKey2019') {
|
|
35
|
+
const bytes = base58btc.baseDecode(method.publicKeyBase58);
|
|
36
|
+
if (bytes.length === 32) return bytes.slice();
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// A malformed/unsupported method must not prevent trying another agreement key.
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Resolve once, then bind encryption to copies of these exact public keys. */
|
|
45
|
+
export const resolveRecipientEncrypters = async (
|
|
46
|
+
recipients: string[],
|
|
47
|
+
resolveDid: (did: string) => Promise<DidDocument>
|
|
48
|
+
): Promise<Encrypter[]> => {
|
|
49
|
+
const groups = await Promise.all(
|
|
50
|
+
[...new Set(recipients)].map(async did => {
|
|
51
|
+
const doc = await resolveDid(did).catch(() => undefined);
|
|
52
|
+
const encrypters: Encrypter[] = [];
|
|
53
|
+
for (const agreement of doc?.keyAgreement ?? []) {
|
|
54
|
+
const method =
|
|
55
|
+
typeof agreement === 'string'
|
|
56
|
+
? [...(doc?.verificationMethod ?? []), ...(doc?.publicKey ?? [])].find(
|
|
57
|
+
(candidate): candidate is KeyMethod =>
|
|
58
|
+
typeof candidate !== 'string' &&
|
|
59
|
+
absoluteKeyId(candidate.id, doc!.id) ===
|
|
60
|
+
absoluteKeyId(agreement, doc!.id)
|
|
61
|
+
)
|
|
62
|
+
: agreement;
|
|
63
|
+
if (!method) continue;
|
|
64
|
+
const key = getX25519Bytes(method);
|
|
65
|
+
if (key) encrypters.push(x25519Encrypter(key, absoluteKeyId(method.id, doc!.id)));
|
|
66
|
+
}
|
|
67
|
+
if (!encrypters.length) {
|
|
68
|
+
throw new TRPCError({
|
|
69
|
+
code: 'BAD_REQUEST',
|
|
70
|
+
message: `Recipient has no usable X25519 key-agreement key: ${did}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return encrypters;
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
return groups.flat();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** DIDKit-compatible DAG-JWE encryption with no further DID resolution or kid matching. */
|
|
80
|
+
export const encryptCredentialForRecipients = async (
|
|
81
|
+
credential: VC,
|
|
82
|
+
encrypters: Encrypter[]
|
|
83
|
+
): Promise<JWE> => {
|
|
84
|
+
if (!encrypters.length) throw new Error('Encryption requires at least one recipient key');
|
|
85
|
+
const jwe = await createJWE(await prepareCleartext(credential), encrypters);
|
|
86
|
+
// Every encrypter is bound to one validated key. No recipient may be silently omitted.
|
|
87
|
+
if (jwe.recipients?.length !== encrypters.length) {
|
|
88
|
+
throw new Error('Encryption did not cover every recipient key');
|
|
89
|
+
}
|
|
90
|
+
return JWEValidator.parse(jwe);
|
|
91
|
+
};
|
|
@@ -8,6 +8,10 @@ import { getDeliveryService, getFrom } from '../services/delivery';
|
|
|
8
8
|
import { IssueEndpointValidator } from 'types/credentials';
|
|
9
9
|
import { t, authorizedDidRoute, openRoute } from '@routes';
|
|
10
10
|
import { getSigningAuthorityLearnCard } from '@helpers/learnCard.helpers';
|
|
11
|
+
import {
|
|
12
|
+
encryptCredentialForRecipients,
|
|
13
|
+
resolveRecipientEncrypters,
|
|
14
|
+
} from '@helpers/recipient-encryption.helpers';
|
|
11
15
|
|
|
12
16
|
const ENDORSEMENT_REQUEST_TEMPLATE_ALIAS =
|
|
13
17
|
environment.POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS ?? '';
|
|
@@ -61,6 +65,14 @@ export const credentialsRouter = t.router({
|
|
|
61
65
|
const saDid = learnCard.id.did();
|
|
62
66
|
console.log('[LCA /credentials/issue] SA LearnCard resolved, DID:', saDid);
|
|
63
67
|
|
|
68
|
+
// Validate and capture each public key once, before signing. Encryption
|
|
69
|
+
// uses these same snapshots even if a DID rotates during issuance.
|
|
70
|
+
const recipientEncrypters = encryption
|
|
71
|
+
? await resolveRecipientEncrypters([saDid, ...encryption.recipients], did =>
|
|
72
|
+
learnCard.invoke.resolveDid(did)
|
|
73
|
+
)
|
|
74
|
+
: [];
|
|
75
|
+
|
|
64
76
|
// Preserve issuer.name/image if the credential has an object-form issuer
|
|
65
77
|
if (typeof credential.issuer === 'object' && credential.issuer !== null) {
|
|
66
78
|
credential.issuer.id = saDid;
|
|
@@ -110,13 +122,26 @@ export const credentialsRouter = t.router({
|
|
|
110
122
|
'[LCA /credentials/issue] Encrypting JWE for recipients:',
|
|
111
123
|
recipients
|
|
112
124
|
);
|
|
113
|
-
const jwe = await
|
|
125
|
+
const jwe = await encryptCredentialForRecipients(
|
|
126
|
+
issuedCredential,
|
|
127
|
+
recipientEncrypters
|
|
128
|
+
).catch((error: unknown) => {
|
|
129
|
+
// An unresolved recipient is a permanent input failure, not an issuer
|
|
130
|
+
// failure. Return 4xx so callers do not retry it as a transient 5xx.
|
|
131
|
+
throw new TRPCError({
|
|
132
|
+
code: 'BAD_REQUEST',
|
|
133
|
+
message: `Unable to encrypt for a recipient: ${
|
|
134
|
+
error instanceof Error ? error.message : String(error)
|
|
135
|
+
}`,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
114
138
|
console.log('[LCA /credentials/issue] JWE created successfully');
|
|
115
139
|
return jwe;
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
return issuedCredential;
|
|
119
143
|
} catch (error) {
|
|
144
|
+
if (error instanceof TRPCError) throw error;
|
|
120
145
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
121
146
|
const errStack = error instanceof Error ? error.stack : undefined;
|
|
122
147
|
console.error('[LCA /credentials/issue] Failed:', {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
environment: { IS_OFFLINE: true, IS_E2E_TEST: false },
|
|
5
|
+
verifyIdToken: vi.fn(),
|
|
6
|
+
getDidAuthVp: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@environment', () => ({ environment: mocks.environment }));
|
|
10
|
+
vi.mock('@firebase', () => ({ default: undefined }));
|
|
11
|
+
vi.mock('firebase-admin', () => ({
|
|
12
|
+
default: { auth: () => ({ verifyIdToken: mocks.verifyIdToken }) },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('@routes', async () => {
|
|
15
|
+
const { initTRPC } = await import('@trpc/server');
|
|
16
|
+
const t = initTRPC.create();
|
|
17
|
+
return { t, openRoute: t.procedure, authorizedDidRoute: t.procedure };
|
|
18
|
+
});
|
|
19
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
20
|
+
getDidWebLearnCard: async () => ({ invoke: { getDidAuthVp: mocks.getDidAuthVp } }),
|
|
21
|
+
getEmptyLearnCard: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@cache', () => ({ default: {} }));
|
|
24
|
+
vi.mock('../services/delivery', () => ({ getDeliveryService: vi.fn(), getFrom: vi.fn() }));
|
|
25
|
+
vi.mock('../helpers/locale.helpers', () => ({ resolveLocaleByEmail: vi.fn() }));
|
|
26
|
+
vi.mock('@helpers/dids.helpers', () => ({ isAuthorizedDID: vi.fn() }));
|
|
27
|
+
|
|
28
|
+
import { firebaseRouter } from './firebase';
|
|
29
|
+
|
|
30
|
+
const tokenFor = (claims: Record<string, string>): string =>
|
|
31
|
+
`e30.${Buffer.from(JSON.stringify({ sub: 'local-user', ...claims })).toString('base64url')}.test`;
|
|
32
|
+
const caller = firebaseRouter.createCaller({} as never);
|
|
33
|
+
|
|
34
|
+
describe('getProofOfLoginVp', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mocks.environment.IS_OFFLINE = true;
|
|
39
|
+
mocks.environment.IS_E2E_TEST = false;
|
|
40
|
+
mocks.getDidAuthVp.mockResolvedValue('signed-proof');
|
|
41
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes the login email without Firebase Admin credentials in local dev', async () => {
|
|
45
|
+
await expect(
|
|
46
|
+
caller.getProofOfLoginVp({ token: tokenFor({ email: 'billy@example.com' }) })
|
|
47
|
+
).resolves.toEqual({ success: true, vp: 'signed-proof' });
|
|
48
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
49
|
+
proofFormat: 'jwt',
|
|
50
|
+
challenge: 'proof-of-login:email:billy@example.com',
|
|
51
|
+
});
|
|
52
|
+
expect(mocks.verifyIdToken).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('supports the e2e token path and Firebase phone_number claim', async () => {
|
|
56
|
+
mocks.environment.IS_OFFLINE = false;
|
|
57
|
+
mocks.environment.IS_E2E_TEST = true;
|
|
58
|
+
await caller.getProofOfLoginVp({ token: tokenFor({ phone_number: '+15555550123' }) });
|
|
59
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
60
|
+
proofFormat: 'jwt',
|
|
61
|
+
challenge: 'proof-of-login:phone:+15555550123',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not sign a proof when the token has no contact method', async () => {
|
|
66
|
+
expect(await caller.getProofOfLoginVp({ token: tokenFor({}) })).toMatchObject({
|
|
67
|
+
success: false,
|
|
68
|
+
});
|
|
69
|
+
expect(mocks.getDidAuthVp).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('requires Firebase verification outside local and e2e environments', async () => {
|
|
73
|
+
mocks.environment.IS_OFFLINE = false;
|
|
74
|
+
mocks.verifyIdToken.mockRejectedValueOnce(new Error('Invalid signature'));
|
|
75
|
+
expect(
|
|
76
|
+
await caller.getProofOfLoginVp({ token: tokenFor({ email: 'forged@example.com' }) })
|
|
77
|
+
).toMatchObject({ success: false });
|
|
78
|
+
expect(mocks.verifyIdToken).toHaveBeenCalledOnce();
|
|
79
|
+
expect(mocks.getDidAuthVp).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses verified claims in production', async () => {
|
|
83
|
+
mocks.environment.IS_OFFLINE = false;
|
|
84
|
+
mocks.verifyIdToken.mockResolvedValueOnce({
|
|
85
|
+
uid: 'real-user',
|
|
86
|
+
email: 'verified@example.com',
|
|
87
|
+
});
|
|
88
|
+
await caller.getProofOfLoginVp({ token: 'firebase-token' });
|
|
89
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
90
|
+
proofFormat: 'jwt',
|
|
91
|
+
challenge: 'proof-of-login:email:verified@example.com',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/routes/firebase.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { resolveLocaleByEmail } from '../helpers/locale.helpers';
|
|
|
20
20
|
import jwtDecode from 'jwt-decode';
|
|
21
21
|
import { getDidWebLearnCard, getEmptyLearnCard } from '@helpers/learnCard.helpers';
|
|
22
22
|
import { isAuthorizedDID } from '@helpers/dids.helpers';
|
|
23
|
+
import { verifyFirebaseToken, getContactMethodFromUser } from '@helpers/auth.helpers';
|
|
23
24
|
|
|
24
25
|
const LOGIN_VERIFICATION_CODE_TEMPLATE_ALIAS =
|
|
25
26
|
environment.POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS || 'login-verification-code';
|
|
@@ -501,16 +502,16 @@ export const firebaseRouter = t.router({
|
|
|
501
502
|
const { token } = input;
|
|
502
503
|
|
|
503
504
|
try {
|
|
504
|
-
|
|
505
|
+
// Match sign-in's local/e2e token handling while retaining Firebase
|
|
506
|
+
// verification outside those environments.
|
|
507
|
+
const user = await verifyFirebaseToken(token);
|
|
508
|
+
const contactMethod = getContactMethodFromUser(user);
|
|
509
|
+
if (!contactMethod) {
|
|
510
|
+
throw new Error('Login token does not contain an email or phone number');
|
|
511
|
+
}
|
|
505
512
|
|
|
506
513
|
const learnCard = await getDidWebLearnCard();
|
|
507
|
-
|
|
508
|
-
let proofOfLoginChallenge = 'proof-of-login:';
|
|
509
|
-
if (decodedToken?.email) {
|
|
510
|
-
proofOfLoginChallenge += 'email:' + decodedToken?.email;
|
|
511
|
-
} else if (decodedToken?.phoneNumber) {
|
|
512
|
-
proofOfLoginChallenge += 'phone:' + decodedToken?.phoneNumber;
|
|
513
|
-
}
|
|
514
|
+
const proofOfLoginChallenge = `proof-of-login:${contactMethod.type}:${contactMethod.value}`;
|
|
514
515
|
|
|
515
516
|
const result = await learnCard.invoke.getDidAuthVp({
|
|
516
517
|
proofFormat: 'jwt',
|
package/test/credentials.spec.ts
CHANGED
|
@@ -96,4 +96,42 @@ describe('Credentials', () => {
|
|
|
96
96
|
);
|
|
97
97
|
expect(proof).toBeDefined();
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
it('rejects an unresolvable encryption recipient as BAD_REQUEST', async () => {
|
|
101
|
+
await expect(
|
|
102
|
+
userA.clients.authorizedDidAuth.credentials.issueCredential({
|
|
103
|
+
credential: userA.learnCard.invoke.newCredential(),
|
|
104
|
+
signingAuthority: {
|
|
105
|
+
name: signingAuthority.name,
|
|
106
|
+
ownerDid: signingAuthority.ownerDid,
|
|
107
|
+
did: signingAuthority.did,
|
|
108
|
+
},
|
|
109
|
+
encryption: { recipients: ['did:example:no-key-agreement'] },
|
|
110
|
+
})
|
|
111
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('encrypts for both subject and owner while excluding an unrelated server key', async () => {
|
|
115
|
+
const student = await getUser('b'.repeat(64));
|
|
116
|
+
const server = await getUser('c'.repeat(64));
|
|
117
|
+
const credential = userA.learnCard.invoke.newCredential();
|
|
118
|
+
credential.credentialSubject = { id: student.learnCard.id.did() };
|
|
119
|
+
const stored = await userA.clients.authorizedDidAuth.credentials.issueCredential({
|
|
120
|
+
credential,
|
|
121
|
+
signingAuthority: {
|
|
122
|
+
name: signingAuthority.name,
|
|
123
|
+
ownerDid: signingAuthority.ownerDid,
|
|
124
|
+
did: signingAuthority.did,
|
|
125
|
+
},
|
|
126
|
+
encryption: { recipients: [student.learnCard.id.did(), userA.learnCard.id.did()] },
|
|
127
|
+
});
|
|
128
|
+
expect(stored).toHaveProperty('ciphertext');
|
|
129
|
+
const decrypted = await student.learnCard.invoke.decryptDagJwe(stored);
|
|
130
|
+
expect(await userA.learnCard.invoke.decryptDagJwe(stored)).toEqual(decrypted);
|
|
131
|
+
// WASM returns an empty value for an inaccessible JWE; native may throw.
|
|
132
|
+
expect(
|
|
133
|
+
await server.learnCard.invoke.decryptDagJwe(stored).catch(() => undefined)
|
|
134
|
+
).toBeFalsy();
|
|
135
|
+
expect(decrypted).toMatchObject({ credentialSubject: credential.credentialSubject });
|
|
136
|
+
});
|
|
99
137
|
});
|