@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 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;
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>;
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: 117740979 bytes
4
- sha256: 98fa6833e9147f766d1c6337b2e91d656ffa66c150bee87bfee4e3d4c9de3fa4
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.0",
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.34",
54
- "@learncard/email-templates": "1.0.6",
55
- "@learncard/did-web-plugin": "1.1.34",
56
- "@learncard/didkit-plugin": "1.9.14",
57
- "@learncard/didkit-plugin-node": "0.2.32",
58
- "@learncard/init": "2.4.15",
59
- "@learncard/helpers": "1.5.0",
60
- "@learncard/types": "5.19.0",
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 learnCard.invoke.createDagJwe(issuedCredential, recipients);
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
+ });
@@ -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
- const decodedToken = await app?.auth().verifyIdToken(token);
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',
@@ -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
  });