@learncard/learn-cloud-service 2.5.34 → 2.5.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/CHANGELOG.md +19 -0
- package/dist/accesslayer/share-content/delete.d.ts +5 -0
- package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
- package/dist/accesslayer/share-content/index.d.ts +8 -0
- package/dist/accesslayer/share-content/index.d.ts.map +1 -0
- package/dist/accesslayer/share-content/initialize.d.ts +5 -0
- package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
- package/dist/accesslayer/share-content/internal.d.ts +12 -0
- package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
- package/dist/accesslayer/share-content/put.d.ts +5 -0
- package/dist/accesslayer/share-content/put.d.ts.map +1 -0
- package/dist/accesslayer/share-content/read.d.ts +7 -0
- package/dist/accesslayer/share-content/read.d.ts.map +1 -0
- package/dist/accesslayer/share-content/types.d.ts +85 -0
- package/dist/accesslayer/share-content/types.d.ts.map +1 -0
- package/dist/config/environment.d.ts +12 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
- package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
- package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/claims.d.ts +19 -0
- package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/config.d.ts +12 -0
- package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/index.d.ts +8 -0
- package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/token.d.ts +28 -0
- package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/types.d.ts +70 -0
- package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
- package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/ShareContent.d.ts +160 -0
- package/dist/models/ShareContent.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/share-content/config.d.ts +28 -0
- package/dist/share-content/config.d.ts.map +1 -0
- package/dist/share-content/index.d.ts +6 -0
- package/dist/share-content/index.d.ts.map +1 -0
- package/dist/share-content/plugin.d.ts +5 -0
- package/dist/share-content/plugin.d.ts.map +1 -0
- package/dist/share-content/production.d.ts +15 -0
- package/dist/share-content/production.d.ts.map +1 -0
- package/dist/share-content/runtime.d.ts +23 -0
- package/dist/share-content/runtime.d.ts.map +1 -0
- package/dist/share-content/types.d.ts +23 -0
- package/dist/share-content/types.d.ts.map +1 -0
- package/lambda.ts +61 -0
- package/package.json +14 -14
- package/serverless.yml +25 -0
- package/src/accesslayer/share-content/delete.ts +124 -0
- package/src/accesslayer/share-content/index.ts +70 -0
- package/src/accesslayer/share-content/initialize.ts +24 -0
- package/src/accesslayer/share-content/internal.ts +89 -0
- package/src/accesslayer/share-content/put.ts +93 -0
- package/src/accesslayer/share-content/read.ts +124 -0
- package/src/accesslayer/share-content/types.ts +155 -0
- package/src/config/environment.ts +33 -0
- package/src/docker-entry.ts +19 -0
- package/src/helpers/share-content-auth/adapters.ts +48 -0
- package/src/helpers/share-content-auth/canonical.ts +91 -0
- package/src/helpers/share-content-auth/claims.ts +164 -0
- package/src/helpers/share-content-auth/config.ts +160 -0
- package/src/helpers/share-content-auth/index.ts +59 -0
- package/src/helpers/share-content-auth/token.ts +148 -0
- package/src/helpers/share-content-auth/types.ts +195 -0
- package/src/helpers/share-content-auth/verifier.ts +220 -0
- package/src/models/ShareContent.ts +198 -0
- package/src/models/index.ts +1 -0
- package/src/share-content/config.ts +199 -0
- package/src/share-content/index.ts +5 -0
- package/src/share-content/plugin.ts +317 -0
- package/src/share-content/production.ts +107 -0
- package/src/share-content/runtime.ts +90 -0
- package/src/share-content/types.ts +59 -0
- package/test/share-content-auth.spec.ts +1161 -0
- package/test/share-content-config.spec.ts +252 -0
- package/test/share-content-didkit.spec.ts +411 -0
- package/test/share-content-endpoint.spec.ts +825 -0
- package/test/share-content-redis-setup.ts +18 -0
- package/test/share-content.spec.ts +681 -0
- package/vitest.config.ts +1 -0
- package/vitest.share-content-config-unit.config.ts +8 -0
- package/vitest.share-content-endpoint.config.ts +21 -0
- package/vitest.share-content.config.ts +19 -0
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_TOKEN_BYTES,
|
|
5
|
+
DISABLED_SHARE_CONTENT_TRUST_CONFIG,
|
|
6
|
+
SHARE_CONTENT_AUTH_PURPOSE,
|
|
7
|
+
ShareContentCanonicalizationError,
|
|
8
|
+
canonicalizeShareContentRequestBody,
|
|
9
|
+
computeShareContentRequestHash,
|
|
10
|
+
createShareContentAuthorizationVerifier,
|
|
11
|
+
createSetIfAbsentReplayStore,
|
|
12
|
+
isCanonicalBase64Url,
|
|
13
|
+
isShareContentTrustConfigActive,
|
|
14
|
+
resolveShareContentTrustConfig,
|
|
15
|
+
type PresentationVerificationResult,
|
|
16
|
+
type PresentationVerifier,
|
|
17
|
+
type ReplayStore,
|
|
18
|
+
type ShareContentAuthorizationRequest,
|
|
19
|
+
type ShareContentAuthorizationVerifier,
|
|
20
|
+
type ShareContentTrustConfig,
|
|
21
|
+
} from '@helpers/share-content-auth';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Fixtures
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const NOW = 1_700_000_000;
|
|
28
|
+
const AUDIENCE = 'did:web:cloud.learncard.com';
|
|
29
|
+
const SIGNER = 'did:web:brain.learncard.com';
|
|
30
|
+
const KID = `${SIGNER}#key-1`;
|
|
31
|
+
const SHARE_ID = Buffer.from(new Uint8Array(16).fill(7)).toString('base64url');
|
|
32
|
+
const OWNER = 'owner-profile-1';
|
|
33
|
+
const OBJECT_ID = 'object-0001';
|
|
34
|
+
const OPERATION_ID = 'operation-0001';
|
|
35
|
+
const NAMESPACE = 'learncard';
|
|
36
|
+
|
|
37
|
+
const ENVELOPE = { v: 1, alg: 'A256GCM', iv: 'AAAAAAAAAAAAAAAA', ct: 'AAAAAAAAAAAAAAAAAAAAAAAA' };
|
|
38
|
+
const RECOVERY = { protected: 'aaa', iv: 'bbb', ciphertext: 'ccc', tag: 'ddd' };
|
|
39
|
+
|
|
40
|
+
const BINDING = {
|
|
41
|
+
namespace: NAMESPACE,
|
|
42
|
+
op: 'put' as const,
|
|
43
|
+
shareId: SHARE_ID,
|
|
44
|
+
contentVersion: 1,
|
|
45
|
+
ownerProfileId: OWNER,
|
|
46
|
+
objectId: OBJECT_ID,
|
|
47
|
+
operationId: OPERATION_ID,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const FULL_BODY = { ...BINDING, ciphertext: ENVELOPE, ownerEncryptedRecovery: RECOVERY };
|
|
51
|
+
const REQUEST_HASH = computeShareContentRequestHash(FULL_BODY);
|
|
52
|
+
|
|
53
|
+
const baseRequest = (): ShareContentAuthorizationRequest => ({
|
|
54
|
+
...BINDING,
|
|
55
|
+
requestHash: REQUEST_HASH,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const baseClaims = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
59
|
+
iss: SIGNER,
|
|
60
|
+
aud: AUDIENCE,
|
|
61
|
+
purpose: SHARE_CONTENT_AUTH_PURPOSE,
|
|
62
|
+
namespace: NAMESPACE,
|
|
63
|
+
op: 'put',
|
|
64
|
+
shareId: SHARE_ID,
|
|
65
|
+
contentVersion: 1,
|
|
66
|
+
ownerProfileId: OWNER,
|
|
67
|
+
objectId: OBJECT_ID,
|
|
68
|
+
operationId: OPERATION_ID,
|
|
69
|
+
requestHash: REQUEST_HASH,
|
|
70
|
+
iat: NOW,
|
|
71
|
+
exp: NOW + 30,
|
|
72
|
+
jti: 'jti-abcdefghijklmnop1234',
|
|
73
|
+
...overrides,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const okVerification = (): PresentationVerificationResult => ({
|
|
77
|
+
checks: ['JWS'],
|
|
78
|
+
warnings: [],
|
|
79
|
+
errors: [],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const activeConfig = (overrides: Record<string, unknown> = {}): ShareContentTrustConfig =>
|
|
83
|
+
resolveShareContentTrustConfig({
|
|
84
|
+
enabled: true,
|
|
85
|
+
audience: AUDIENCE,
|
|
86
|
+
serviceDids: [SIGNER],
|
|
87
|
+
verificationMethods: [KID],
|
|
88
|
+
...overrides,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const encodePart = (value: unknown): string =>
|
|
92
|
+
Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
|
93
|
+
|
|
94
|
+
const DUMMY_SIGNATURE = Buffer.from(new Uint8Array(64).fill(1)).toString('base64url');
|
|
95
|
+
|
|
96
|
+
const buildTokenFromPayload = (payload: unknown, header: Record<string, unknown> = {}): string =>
|
|
97
|
+
`${encodePart({ alg: 'EdDSA', kid: KID, ...header })}.${encodePart(payload)}.${DUMMY_SIGNATURE}`;
|
|
98
|
+
|
|
99
|
+
const buildToken = (claims: unknown, header: Record<string, unknown> = {}): string =>
|
|
100
|
+
buildTokenFromPayload(
|
|
101
|
+
{
|
|
102
|
+
iss: SIGNER,
|
|
103
|
+
vp: {
|
|
104
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
105
|
+
type: ['VerifiablePresentation'],
|
|
106
|
+
holder: SIGNER,
|
|
107
|
+
verifiableCredential: [],
|
|
108
|
+
},
|
|
109
|
+
nonce: typeof claims === 'string' ? claims : JSON.stringify(claims),
|
|
110
|
+
},
|
|
111
|
+
header
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const makeAtomicStore = (): { store: ReplayStore; keys: Map<string, number> } => {
|
|
115
|
+
const keys = new Map<string, number>();
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
keys,
|
|
119
|
+
store: {
|
|
120
|
+
consumeOnce: async (key, ttlSeconds) => {
|
|
121
|
+
if (keys.has(key)) return false;
|
|
122
|
+
|
|
123
|
+
// Written synchronously before any await → atomic in the event loop,
|
|
124
|
+
// the same contract Redis SET NX EX provides.
|
|
125
|
+
keys.set(key, ttlSeconds);
|
|
126
|
+
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const makeVerifier = (
|
|
134
|
+
options: {
|
|
135
|
+
config?: ShareContentTrustConfig;
|
|
136
|
+
verifyResult?: PresentationVerificationResult;
|
|
137
|
+
verifyImpl?: PresentationVerifier;
|
|
138
|
+
store?: ReplayStore;
|
|
139
|
+
now?: () => number;
|
|
140
|
+
} = {}
|
|
141
|
+
): {
|
|
142
|
+
verifier: ShareContentAuthorizationVerifier;
|
|
143
|
+
calls: string[];
|
|
144
|
+
config: ShareContentTrustConfig;
|
|
145
|
+
} => {
|
|
146
|
+
const calls: string[] = [];
|
|
147
|
+
const verifyPresentation: PresentationVerifier =
|
|
148
|
+
options.verifyImpl ??
|
|
149
|
+
(async token => {
|
|
150
|
+
calls.push(token);
|
|
151
|
+
|
|
152
|
+
return options.verifyResult ?? okVerification();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const config = options.config ?? activeConfig();
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
config,
|
|
159
|
+
calls,
|
|
160
|
+
verifier: createShareContentAuthorizationVerifier({
|
|
161
|
+
config,
|
|
162
|
+
verifyPresentation,
|
|
163
|
+
replayStore: options.store ?? makeAtomicStore().store,
|
|
164
|
+
now: options.now ?? (() => NOW),
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const expectReject = async (
|
|
170
|
+
result: Promise<{ ok: boolean; reason?: string }>,
|
|
171
|
+
reason: string
|
|
172
|
+
): Promise<void> => {
|
|
173
|
+
const settled = await result;
|
|
174
|
+
|
|
175
|
+
expect(settled.ok).toBe(false);
|
|
176
|
+
expect(settled.reason).toBe(reason);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Trust configuration
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe('share-content-auth trust configuration', () => {
|
|
184
|
+
it('treats an empty configuration as disabled', () => {
|
|
185
|
+
expect(isShareContentTrustConfigActive(resolveShareContentTrustConfig({}))).toBe(false);
|
|
186
|
+
expect(isShareContentTrustConfigActive(DISABLED_SHARE_CONTENT_TRUST_CONFIG)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('stays disabled when explicitly disabled even with full values', () => {
|
|
190
|
+
const config = resolveShareContentTrustConfig({
|
|
191
|
+
enabled: false,
|
|
192
|
+
audience: AUDIENCE,
|
|
193
|
+
serviceDids: [SIGNER],
|
|
194
|
+
verificationMethods: [KID],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(config.enabled).toBe(false);
|
|
198
|
+
expect(isShareContentTrustConfigActive(config)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('disables access when a DID allowlist is present but the key-method allowlist is missing', () => {
|
|
202
|
+
expect(
|
|
203
|
+
resolveShareContentTrustConfig({
|
|
204
|
+
enabled: true,
|
|
205
|
+
audience: AUDIENCE,
|
|
206
|
+
serviceDids: [SIGNER],
|
|
207
|
+
}).enabled
|
|
208
|
+
).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('parses comma-separated allowlists and bounds the numbers', () => {
|
|
212
|
+
const config = resolveShareContentTrustConfig({
|
|
213
|
+
enabled: true,
|
|
214
|
+
audience: AUDIENCE,
|
|
215
|
+
serviceDids: `${SIGNER}, did:web:brain2.learncard.com`,
|
|
216
|
+
verificationMethods: `${KID},${SIGNER}#key-2`,
|
|
217
|
+
clockSkewSeconds: 10,
|
|
218
|
+
maxTokenTtlSeconds: 30,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(isShareContentTrustConfigActive(config)).toBe(true);
|
|
222
|
+
expect(config.allowedServiceDids).toEqual([SIGNER, 'did:web:brain2.learncard.com']);
|
|
223
|
+
expect(config.clockSkewSeconds).toBe(10);
|
|
224
|
+
expect(config.maxTokenTtlSeconds).toBe(30);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects out-of-range bounds by disabling access', () => {
|
|
228
|
+
expect(
|
|
229
|
+
resolveShareContentTrustConfig({
|
|
230
|
+
enabled: true,
|
|
231
|
+
audience: AUDIENCE,
|
|
232
|
+
serviceDids: [SIGNER],
|
|
233
|
+
verificationMethods: [KID],
|
|
234
|
+
maxTokenTtlSeconds: 600,
|
|
235
|
+
}).enabled
|
|
236
|
+
).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('uses default-disabled config so the verifier denies access with no trusted identities', async () => {
|
|
240
|
+
const verify = vi.fn<PresentationVerifier>(async () => okVerification());
|
|
241
|
+
const verifier = createShareContentAuthorizationVerifier({
|
|
242
|
+
config: DISABLED_SHARE_CONTENT_TRUST_CONFIG,
|
|
243
|
+
verifyPresentation: verify,
|
|
244
|
+
replayStore: makeAtomicStore().store,
|
|
245
|
+
now: () => NOW,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await expectReject(verifier.authorize(baseRequest(), buildToken(baseClaims())), 'DISABLED');
|
|
249
|
+
expect(verify).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does not honor an offline/unknown config flag as an access bypass', async () => {
|
|
253
|
+
const config = resolveShareContentTrustConfig({
|
|
254
|
+
enabled: true,
|
|
255
|
+
audience: AUDIENCE,
|
|
256
|
+
serviceDids: [SIGNER],
|
|
257
|
+
verificationMethods: [KID],
|
|
258
|
+
allowOffline: true,
|
|
259
|
+
offline: true,
|
|
260
|
+
SHARE_CONTENT_ALLOW_OFFLINE: true,
|
|
261
|
+
});
|
|
262
|
+
const { verifier } = makeVerifier({ config });
|
|
263
|
+
|
|
264
|
+
await expectReject(
|
|
265
|
+
verifier.authorize(
|
|
266
|
+
baseRequest(),
|
|
267
|
+
buildToken(baseClaims(), { kid: 'did:web:localhost%3A3000#key-1' })
|
|
268
|
+
),
|
|
269
|
+
'UNKNOWN_SIGNER'
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Allowlist filtering BEFORE the signature verifier
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
describe('share-content-auth pre-verification allowlist filtering', () => {
|
|
279
|
+
it('rejects an ordinary user DID without invoking the verifier', async () => {
|
|
280
|
+
const userDid = 'did:key:z6MkUserNotAService';
|
|
281
|
+
const { verifier, calls } = makeVerifier();
|
|
282
|
+
|
|
283
|
+
await expectReject(
|
|
284
|
+
verifier.authorize(
|
|
285
|
+
baseRequest(),
|
|
286
|
+
buildToken(baseClaims(), { kid: `${userDid}#key-1` })
|
|
287
|
+
),
|
|
288
|
+
'UNKNOWN_SIGNER'
|
|
289
|
+
);
|
|
290
|
+
expect(calls).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('rejects an unknown verification method on an allowlisted DID without invoking the verifier', async () => {
|
|
294
|
+
const { verifier, calls } = makeVerifier();
|
|
295
|
+
|
|
296
|
+
await expectReject(
|
|
297
|
+
verifier.authorize(
|
|
298
|
+
baseRequest(),
|
|
299
|
+
buildToken(baseClaims(), { kid: `${SIGNER}#rotated-unknown` })
|
|
300
|
+
),
|
|
301
|
+
'UNKNOWN_VERIFICATION_METHOD'
|
|
302
|
+
);
|
|
303
|
+
expect(calls).toHaveLength(0);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('rejects remote key headers and unsupported algorithms without invoking the verifier', async () => {
|
|
307
|
+
const { verifier, calls } = makeVerifier();
|
|
308
|
+
|
|
309
|
+
await expectReject(
|
|
310
|
+
verifier.authorize(
|
|
311
|
+
baseRequest(),
|
|
312
|
+
buildToken(baseClaims(), { jku: 'https://evil.example/jwks' })
|
|
313
|
+
),
|
|
314
|
+
'UNSUPPORTED_HEADER'
|
|
315
|
+
);
|
|
316
|
+
await expectReject(
|
|
317
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims(), { jwk: { kty: 'OKP' } })),
|
|
318
|
+
'UNSUPPORTED_HEADER'
|
|
319
|
+
);
|
|
320
|
+
await expectReject(
|
|
321
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims(), { alg: 'none' })),
|
|
322
|
+
'UNSUPPORTED_ALGORITHM'
|
|
323
|
+
);
|
|
324
|
+
await expectReject(
|
|
325
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims(), { alg: 'HS256' })),
|
|
326
|
+
'UNSUPPORTED_ALGORITHM'
|
|
327
|
+
);
|
|
328
|
+
expect(calls).toHaveLength(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('rejects a missing kid without invoking the verifier', async () => {
|
|
332
|
+
const { verifier, calls } = makeVerifier();
|
|
333
|
+
|
|
334
|
+
await expectReject(
|
|
335
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims(), { kid: undefined })),
|
|
336
|
+
'MISSING_KID'
|
|
337
|
+
);
|
|
338
|
+
expect(calls).toHaveLength(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('rejects a non-DID / URL-shaped kid', async () => {
|
|
342
|
+
const { verifier, calls } = makeVerifier();
|
|
343
|
+
|
|
344
|
+
await expectReject(
|
|
345
|
+
verifier.authorize(
|
|
346
|
+
baseRequest(),
|
|
347
|
+
buildToken(baseClaims(), { kid: 'https://evil.example#key' })
|
|
348
|
+
),
|
|
349
|
+
'UNSUPPORTED_HEADER'
|
|
350
|
+
);
|
|
351
|
+
expect(calls).toHaveLength(0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('rejects an oversized token before any parsing or verification', async () => {
|
|
355
|
+
const { verifier, calls } = makeVerifier({
|
|
356
|
+
config: activeConfig({ maxTokenBytes: 512 }),
|
|
357
|
+
});
|
|
358
|
+
const hugeClaims = baseClaims({ padding: 'x'.repeat(4000) });
|
|
359
|
+
|
|
360
|
+
await expectReject(
|
|
361
|
+
verifier.authorize(baseRequest(), buildToken(hugeClaims)),
|
|
362
|
+
'TOKEN_TOO_LARGE'
|
|
363
|
+
);
|
|
364
|
+
expect(calls).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Cryptographic verification and identity binding
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
describe('share-content-auth signature and identity binding', () => {
|
|
373
|
+
it('accepts a valid token and returns only minimal authenticated context', async () => {
|
|
374
|
+
const { verifier } = makeVerifier();
|
|
375
|
+
const token = buildToken(baseClaims());
|
|
376
|
+
|
|
377
|
+
const result = await verifier.authorize(baseRequest(), token);
|
|
378
|
+
|
|
379
|
+
expect(result.ok).toBe(true);
|
|
380
|
+
if (!result.ok) return;
|
|
381
|
+
|
|
382
|
+
expect(result.context.signerDid).toBe(SIGNER);
|
|
383
|
+
expect(result.context.verificationMethod).toBe(KID);
|
|
384
|
+
expect(result.context.claims.shareId).toBe(SHARE_ID);
|
|
385
|
+
expect(JSON.stringify(result.context)).not.toContain(token);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('accepts claims supplied through vp.proof.challenge', async () => {
|
|
389
|
+
const { verifier } = makeVerifier();
|
|
390
|
+
const token = buildTokenFromPayload({
|
|
391
|
+
iss: SIGNER,
|
|
392
|
+
vp: {
|
|
393
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
394
|
+
type: ['VerifiablePresentation'],
|
|
395
|
+
holder: SIGNER,
|
|
396
|
+
proof: { challenge: JSON.stringify(baseClaims()) },
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const result = await verifier.authorize(baseRequest(), token);
|
|
401
|
+
expect(result.ok).toBe(true);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('rejects a bad signature', async () => {
|
|
405
|
+
const { verifier, calls } = makeVerifier({
|
|
406
|
+
verifyResult: { checks: ['JWS'], warnings: [], errors: ['Invalid signature'] },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await expectReject(
|
|
410
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims())),
|
|
411
|
+
'SIGNATURE_INVALID'
|
|
412
|
+
);
|
|
413
|
+
expect(calls).toHaveLength(1);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('rejects a verification result without the JWS check', async () => {
|
|
417
|
+
const { verifier } = makeVerifier({
|
|
418
|
+
verifyResult: { checks: [], warnings: [], errors: [] },
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await expectReject(
|
|
422
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims())),
|
|
423
|
+
'SIGNATURE_INVALID'
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('fails closed when the injected verifier throws', async () => {
|
|
428
|
+
const { verifier } = makeVerifier({
|
|
429
|
+
verifyImpl: async () => {
|
|
430
|
+
throw new Error('resolver unavailable');
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await expectReject(
|
|
435
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims())),
|
|
436
|
+
'SIGNATURE_INVALID'
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('rejects a token whose payload iss does not match the signing kid', async () => {
|
|
441
|
+
const { verifier, calls } = makeVerifier();
|
|
442
|
+
const token = buildTokenFromPayload({
|
|
443
|
+
iss: 'did:web:attacker.example',
|
|
444
|
+
vp: { holder: SIGNER },
|
|
445
|
+
nonce: JSON.stringify(baseClaims()),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await expectReject(verifier.authorize(baseRequest(), token), 'SIGNER_MISMATCH');
|
|
449
|
+
expect(calls).toHaveLength(1);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('rejects a token whose holder does not match the signing kid', async () => {
|
|
453
|
+
const { verifier } = makeVerifier();
|
|
454
|
+
const token = buildTokenFromPayload({
|
|
455
|
+
iss: SIGNER,
|
|
456
|
+
vp: { holder: 'did:web:attacker.example' },
|
|
457
|
+
nonce: JSON.stringify(baseClaims()),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await expectReject(verifier.authorize(baseRequest(), token), 'SIGNER_MISMATCH');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('rejects claims whose iss does not match the signing kid', async () => {
|
|
464
|
+
const { verifier } = makeVerifier();
|
|
465
|
+
const token = buildToken(baseClaims({ iss: 'did:web:attacker.example' }));
|
|
466
|
+
|
|
467
|
+
await expectReject(verifier.authorize(baseRequest(), token), 'SIGNER_MISMATCH');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('rejects malformed JSON claims and a non-JSON nonce', async () => {
|
|
471
|
+
const { verifier } = makeVerifier();
|
|
472
|
+
|
|
473
|
+
await expectReject(
|
|
474
|
+
verifier.authorize(baseRequest(), buildToken('{not json')),
|
|
475
|
+
'MALFORMED_CLAIMS'
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Claim schema, purpose and audience
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
describe('share-content-auth claims', () => {
|
|
485
|
+
it('rejects a wrong audience', async () => {
|
|
486
|
+
const { verifier } = makeVerifier();
|
|
487
|
+
|
|
488
|
+
await expectReject(
|
|
489
|
+
verifier.authorize(
|
|
490
|
+
baseRequest(),
|
|
491
|
+
buildToken(baseClaims({ aud: 'did:web:evil.example' }))
|
|
492
|
+
),
|
|
493
|
+
'AUDIENCE_MISMATCH'
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('rejects a wrong purpose', async () => {
|
|
498
|
+
const { verifier } = makeVerifier();
|
|
499
|
+
|
|
500
|
+
await expectReject(
|
|
501
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ purpose: 'user-auth' }))),
|
|
502
|
+
'PURPOSE_MISMATCH'
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('rejects wildcard and unknown operations', async () => {
|
|
507
|
+
const { verifier } = makeVerifier();
|
|
508
|
+
|
|
509
|
+
for (const op of ['list', 'deleteOwner', 'deleteAll']) {
|
|
510
|
+
await expectReject(
|
|
511
|
+
verifier.authorize({ ...baseRequest(), op: 'put' }, buildToken(baseClaims({ op }))),
|
|
512
|
+
'OPERATION_NOT_ALLOWED'
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('accepts the exact-object operations and the owner-recovery intent', async () => {
|
|
518
|
+
for (const op of ['get', 'stat', 'delete', 'readRecovery'] as const) {
|
|
519
|
+
const hash = computeShareContentRequestHash({ ...BINDING, op });
|
|
520
|
+
const claims = baseClaims({ op, requestHash: hash });
|
|
521
|
+
const { verifier } = makeVerifier();
|
|
522
|
+
|
|
523
|
+
const result = await verifier.authorize(
|
|
524
|
+
{ ...BINDING, op, requestHash: hash },
|
|
525
|
+
buildToken(claims)
|
|
526
|
+
);
|
|
527
|
+
expect(result.ok).toBe(true);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('rejects unknown claim fields', async () => {
|
|
532
|
+
const { verifier } = makeVerifier();
|
|
533
|
+
|
|
534
|
+
await expectReject(
|
|
535
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ admin: true }))),
|
|
536
|
+
'UNKNOWN_CLAIM_FIELD'
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('rejects non-canonical / URL-shaped share ids in claims', async () => {
|
|
541
|
+
const { verifier } = makeVerifier();
|
|
542
|
+
const request = { ...baseRequest() };
|
|
543
|
+
|
|
544
|
+
await expectReject(
|
|
545
|
+
verifier.authorize(
|
|
546
|
+
request,
|
|
547
|
+
buildToken(baseClaims({ shareId: 'https://evil.example/blob' }))
|
|
548
|
+
),
|
|
549
|
+
'INVALID_SHARE_ID'
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// Non-canonical spelling of the same 16 bytes (unused padding bits set).
|
|
553
|
+
const nonCanonical = `${SHARE_ID.slice(0, 21)}B`;
|
|
554
|
+
await expectReject(
|
|
555
|
+
verifier.authorize(request, buildToken(baseClaims({ shareId: nonCanonical }))),
|
|
556
|
+
'INVALID_SHARE_ID'
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('rejects non-opaque owner/object/operation/namespace identifiers', async () => {
|
|
561
|
+
const { verifier } = makeVerifier();
|
|
562
|
+
|
|
563
|
+
await expectReject(
|
|
564
|
+
verifier.authorize(
|
|
565
|
+
baseRequest(),
|
|
566
|
+
buildToken(baseClaims({ ownerProfileId: 'has space' }))
|
|
567
|
+
),
|
|
568
|
+
'INVALID_IDENTIFIER'
|
|
569
|
+
);
|
|
570
|
+
await expectReject(
|
|
571
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ objectId: '' }))),
|
|
572
|
+
'INVALID_IDENTIFIER'
|
|
573
|
+
);
|
|
574
|
+
await expectReject(
|
|
575
|
+
verifier.authorize(
|
|
576
|
+
baseRequest(),
|
|
577
|
+
buildToken(baseClaims({ operationId: 'a'.repeat(129) }))
|
|
578
|
+
),
|
|
579
|
+
'INVALID_IDENTIFIER'
|
|
580
|
+
);
|
|
581
|
+
await expectReject(
|
|
582
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ namespace: 'bad\nvalue' }))),
|
|
583
|
+
'INVALID_IDENTIFIER'
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('rejects non-canonical request hashes', async () => {
|
|
588
|
+
const { verifier } = makeVerifier();
|
|
589
|
+
|
|
590
|
+
await expectReject(
|
|
591
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ requestHash: 'deadbeef' }))),
|
|
592
|
+
'INVALID_CLAIMS'
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Time and nonce
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
describe('share-content-auth time and nonce bounds', () => {
|
|
602
|
+
it('rejects non-positive, zero and too-long signed lifetimes', async () => {
|
|
603
|
+
const { verifier } = makeVerifier();
|
|
604
|
+
|
|
605
|
+
await expectReject(
|
|
606
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ iat: NOW, exp: NOW }))),
|
|
607
|
+
'INVALID_TTL'
|
|
608
|
+
);
|
|
609
|
+
await expectReject(
|
|
610
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ iat: NOW + 10, exp: NOW }))),
|
|
611
|
+
'INVALID_TTL'
|
|
612
|
+
);
|
|
613
|
+
await expectReject(
|
|
614
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ iat: NOW, exp: NOW + 61 }))),
|
|
615
|
+
'INVALID_TTL'
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('rejects non-integer and unsafe times', async () => {
|
|
620
|
+
const { verifier } = makeVerifier();
|
|
621
|
+
|
|
622
|
+
await expectReject(
|
|
623
|
+
verifier.authorize(
|
|
624
|
+
baseRequest(),
|
|
625
|
+
buildToken(baseClaims({ iat: NOW + 0.5, exp: NOW + 30 }))
|
|
626
|
+
),
|
|
627
|
+
'INVALID_CLAIMS'
|
|
628
|
+
);
|
|
629
|
+
await expectReject(
|
|
630
|
+
verifier.authorize(
|
|
631
|
+
baseRequest(),
|
|
632
|
+
buildToken(baseClaims({ iat: NOW, exp: Number.MAX_SAFE_INTEGER + 1 }))
|
|
633
|
+
),
|
|
634
|
+
'INVALID_CLAIMS'
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('rejects an expired token but tolerates bounded skew', async () => {
|
|
639
|
+
const { verifier } = makeVerifier();
|
|
640
|
+
|
|
641
|
+
await expectReject(
|
|
642
|
+
verifier.authorize(
|
|
643
|
+
baseRequest(),
|
|
644
|
+
buildToken(baseClaims({ iat: NOW - 120, exp: NOW - 60 }))
|
|
645
|
+
),
|
|
646
|
+
'TOKEN_EXPIRED'
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const withinSkew = makeVerifier();
|
|
650
|
+
const expiredWithinSkew = buildToken(baseClaims({ iat: NOW - 40, exp: NOW - 10 }));
|
|
651
|
+
// exp + skew (5) is still in the past → rejected
|
|
652
|
+
await expectReject(
|
|
653
|
+
withinSkew.verifier.authorize(baseRequest(), expiredWithinSkew),
|
|
654
|
+
'TOKEN_EXPIRED'
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
const config = activeConfig({ clockSkewSeconds: 30 });
|
|
658
|
+
const skewed = makeVerifier({ config });
|
|
659
|
+
const result = await skewed.verifier.authorize(
|
|
660
|
+
baseRequest(),
|
|
661
|
+
buildToken(baseClaims({ iat: NOW - 40, exp: NOW - 10 }))
|
|
662
|
+
);
|
|
663
|
+
expect(result.ok).toBe(true);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('rejects a token issued in the future beyond skew', async () => {
|
|
667
|
+
const { verifier } = makeVerifier();
|
|
668
|
+
|
|
669
|
+
await expectReject(
|
|
670
|
+
verifier.authorize(
|
|
671
|
+
baseRequest(),
|
|
672
|
+
buildToken(baseClaims({ iat: NOW + 60, exp: NOW + 90 }))
|
|
673
|
+
),
|
|
674
|
+
'TOKEN_NOT_YET_VALID'
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('accepts an iat at the future-skew boundary and reserves the full expiry+skew window', async () => {
|
|
679
|
+
const store = makeAtomicStore();
|
|
680
|
+
const { verifier } = makeVerifier({ store: store.store });
|
|
681
|
+
|
|
682
|
+
const result = await verifier.authorize(
|
|
683
|
+
baseRequest(),
|
|
684
|
+
buildToken(baseClaims({ iat: NOW + 5, exp: NOW + 65 }))
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
expect(result.ok).toBe(true);
|
|
688
|
+
// exp + skew - now = 70 = maxTokenTtl (60) + 2 * skew (5).
|
|
689
|
+
expect([...store.keys.values()]).toEqual([70]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('rejects missing, short, long and non-string nonces', async () => {
|
|
693
|
+
const { verifier } = makeVerifier();
|
|
694
|
+
|
|
695
|
+
await expectReject(
|
|
696
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ jti: undefined }))),
|
|
697
|
+
'INVALID_JTI'
|
|
698
|
+
);
|
|
699
|
+
await expectReject(
|
|
700
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ jti: 'short' }))),
|
|
701
|
+
'INVALID_JTI'
|
|
702
|
+
);
|
|
703
|
+
await expectReject(
|
|
704
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ jti: 'a'.repeat(200) }))),
|
|
705
|
+
'INVALID_JTI'
|
|
706
|
+
);
|
|
707
|
+
await expectReject(
|
|
708
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims({ jti: 12345 }))),
|
|
709
|
+
'INVALID_JTI'
|
|
710
|
+
);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// Exact request binding and body hash
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
describe('share-content-auth request binding', () => {
|
|
719
|
+
it.each([
|
|
720
|
+
['namespace', { namespace: 'other-namespace' }],
|
|
721
|
+
['op', { op: 'delete' as const }],
|
|
722
|
+
['shareId', { shareId: Buffer.from(new Uint8Array(16).fill(9)).toString('base64url') }],
|
|
723
|
+
['contentVersion', { contentVersion: 2 }],
|
|
724
|
+
['ownerProfileId', { ownerProfileId: 'other-owner' }],
|
|
725
|
+
['objectId', { objectId: 'other-object' }],
|
|
726
|
+
['operationId', { operationId: 'other-operation' }],
|
|
727
|
+
])('rejects a substituted %s', async (_label, override) => {
|
|
728
|
+
const { verifier } = makeVerifier();
|
|
729
|
+
const token = buildToken(baseClaims());
|
|
730
|
+
const request = { ...baseRequest(), ...override } as ShareContentAuthorizationRequest;
|
|
731
|
+
|
|
732
|
+
await expectReject(verifier.authorize(request, token), 'REQUEST_BINDING_MISMATCH');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('rejects a mismatched full-body / recovery hash', async () => {
|
|
736
|
+
const { verifier } = makeVerifier();
|
|
737
|
+
const token = buildToken(baseClaims());
|
|
738
|
+
|
|
739
|
+
const tamperedBody = {
|
|
740
|
+
...FULL_BODY,
|
|
741
|
+
ownerEncryptedRecovery: { ...RECOVERY, ciphertext: 'tampered' },
|
|
742
|
+
};
|
|
743
|
+
const tamperedRequest = {
|
|
744
|
+
...baseRequest(),
|
|
745
|
+
requestHash: computeShareContentRequestHash(tamperedBody),
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
await expectReject(verifier.authorize(tamperedRequest, token), 'REQUEST_HASH_MISMATCH');
|
|
749
|
+
|
|
750
|
+
const tamperedCiphertext = {
|
|
751
|
+
...FULL_BODY,
|
|
752
|
+
ciphertext: { ...ENVELOPE, ct: 'tampered' },
|
|
753
|
+
};
|
|
754
|
+
await expectReject(
|
|
755
|
+
verifier.authorize(
|
|
756
|
+
{
|
|
757
|
+
...baseRequest(),
|
|
758
|
+
requestHash: computeShareContentRequestHash(tamperedCiphertext),
|
|
759
|
+
},
|
|
760
|
+
token
|
|
761
|
+
),
|
|
762
|
+
'REQUEST_HASH_MISMATCH'
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('rejects a malformed expected request before touching the verifier', async () => {
|
|
767
|
+
const { verifier, calls } = makeVerifier();
|
|
768
|
+
const badRequest = { ...baseRequest(), shareId: 'https://evil.example/blob' };
|
|
769
|
+
|
|
770
|
+
await expectReject(
|
|
771
|
+
verifier.authorize(badRequest, buildToken(baseClaims())),
|
|
772
|
+
'INVALID_REQUEST'
|
|
773
|
+
);
|
|
774
|
+
expect(calls).toHaveLength(0);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
// Atomic replay protection
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
describe('share-content-auth replay protection', () => {
|
|
783
|
+
it('consumes a nonce once and rejects a replay of the same token', async () => {
|
|
784
|
+
const store = makeAtomicStore();
|
|
785
|
+
const { verifier } = makeVerifier({ store: store.store });
|
|
786
|
+
const token = buildToken(baseClaims());
|
|
787
|
+
|
|
788
|
+
const first = await verifier.authorize(baseRequest(), token);
|
|
789
|
+
expect(first.ok).toBe(true);
|
|
790
|
+
|
|
791
|
+
await expectReject(verifier.authorize(baseRequest(), token), 'REPLAY_DETECTED');
|
|
792
|
+
expect(store.keys.size).toBe(1);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('allows a fresh signed token (new nonce) to retry the same idempotent operation', async () => {
|
|
796
|
+
const store = makeAtomicStore();
|
|
797
|
+
const { verifier } = makeVerifier({ store: store.store });
|
|
798
|
+
|
|
799
|
+
const first = await verifier.authorize(
|
|
800
|
+
baseRequest(),
|
|
801
|
+
buildToken(baseClaims({ jti: 'jti-first-token-000001' }))
|
|
802
|
+
);
|
|
803
|
+
const retry = await verifier.authorize(
|
|
804
|
+
baseRequest(),
|
|
805
|
+
buildToken(baseClaims({ jti: 'jti-second-token-00002' }))
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
expect(first.ok).toBe(true);
|
|
809
|
+
expect(retry.ok).toBe(true);
|
|
810
|
+
expect(store.keys.size).toBe(2);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('consumes a nonce exactly once under concurrent requests', async () => {
|
|
814
|
+
const store = makeAtomicStore();
|
|
815
|
+
const { verifier } = makeVerifier({ store: store.store });
|
|
816
|
+
const token = buildToken(baseClaims());
|
|
817
|
+
|
|
818
|
+
const settled = await Promise.all([
|
|
819
|
+
verifier.authorize(baseRequest(), token),
|
|
820
|
+
verifier.authorize(baseRequest(), token),
|
|
821
|
+
verifier.authorize(baseRequest(), token),
|
|
822
|
+
]);
|
|
823
|
+
|
|
824
|
+
const accepted = settled.filter(result => result.ok).length;
|
|
825
|
+
const replayed = settled.filter(
|
|
826
|
+
result => !result.ok && result.reason === 'REPLAY_DETECTED'
|
|
827
|
+
).length;
|
|
828
|
+
|
|
829
|
+
expect(accepted).toBe(1);
|
|
830
|
+
expect(replayed).toBe(2);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('does not consume a nonce when the expected request does not match', async () => {
|
|
834
|
+
const store = makeAtomicStore();
|
|
835
|
+
const { verifier } = makeVerifier({ store: store.store });
|
|
836
|
+
const token = buildToken(baseClaims());
|
|
837
|
+
|
|
838
|
+
await expectReject(
|
|
839
|
+
verifier.authorize({ ...baseRequest(), contentVersion: 2 }, token),
|
|
840
|
+
'REQUEST_BINDING_MISMATCH'
|
|
841
|
+
);
|
|
842
|
+
expect(store.keys.size).toBe(0);
|
|
843
|
+
|
|
844
|
+
const retry = await verifier.authorize(baseRequest(), token);
|
|
845
|
+
expect(retry.ok).toBe(true);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('fails closed when the replay store is unavailable', async () => {
|
|
849
|
+
const failingStore: ReplayStore = {
|
|
850
|
+
consumeOnce: async () => {
|
|
851
|
+
throw new Error('redis unavailable');
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
const { verifier } = makeVerifier({ store: failingStore });
|
|
855
|
+
|
|
856
|
+
await expectReject(
|
|
857
|
+
verifier.authorize(baseRequest(), buildToken(baseClaims())),
|
|
858
|
+
'REPLAY_STORE_UNAVAILABLE'
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('adapts an atomic set-if-absent primitive into the replay store contract', async () => {
|
|
863
|
+
const keys = new Set<string>();
|
|
864
|
+
const store = createSetIfAbsentReplayStore(async (key, ttlSeconds) => {
|
|
865
|
+
expect(ttlSeconds).toBeGreaterThan(0);
|
|
866
|
+
if (keys.has(key)) return false;
|
|
867
|
+
keys.add(key);
|
|
868
|
+
|
|
869
|
+
return true;
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
expect(await store.consumeOnce('k', 10)).toBe(true);
|
|
873
|
+
expect(await store.consumeOnce('k', 10)).toBe(false);
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
// Canonical body hash
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
|
|
881
|
+
describe('share-content-auth canonical request hash', () => {
|
|
882
|
+
it('is deterministic across object key order', () => {
|
|
883
|
+
const first = computeShareContentRequestHash({
|
|
884
|
+
b: 2,
|
|
885
|
+
a: 1,
|
|
886
|
+
nested: { z: true, y: null },
|
|
887
|
+
});
|
|
888
|
+
const second = computeShareContentRequestHash({
|
|
889
|
+
nested: { y: null, z: true },
|
|
890
|
+
a: 1,
|
|
891
|
+
b: 2,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
expect(first).toBe(second);
|
|
895
|
+
expect(first).toMatch(/^[0-9a-f]{64}$/);
|
|
896
|
+
expect(canonicalizeShareContentRequestBody({ b: 2, a: 1 })).toBe('{"a":1,"b":2}');
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('changes when the opaque recovery or ciphertext changes', () => {
|
|
900
|
+
const base = computeShareContentRequestHash(FULL_BODY);
|
|
901
|
+
const recoveryChanged = computeShareContentRequestHash({
|
|
902
|
+
...FULL_BODY,
|
|
903
|
+
ownerEncryptedRecovery: { ...RECOVERY, ciphertext: 'different' },
|
|
904
|
+
});
|
|
905
|
+
const ciphertextChanged = computeShareContentRequestHash({
|
|
906
|
+
...FULL_BODY,
|
|
907
|
+
ciphertext: { ...ENVELOPE, ct: 'different' },
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
expect(recoveryChanged).not.toBe(base);
|
|
911
|
+
expect(ciphertextChanged).not.toBe(base);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('rejects unsupported values instead of coercing them', () => {
|
|
915
|
+
const cyclic: Record<string, unknown> = { a: 1 };
|
|
916
|
+
cyclic.self = cyclic;
|
|
917
|
+
|
|
918
|
+
class Custom {
|
|
919
|
+
value = 1;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const sparse: unknown[] = [];
|
|
923
|
+
sparse[2] = 'x';
|
|
924
|
+
|
|
925
|
+
for (const value of [
|
|
926
|
+
{ n: 1.5 },
|
|
927
|
+
{ n: Number.NaN },
|
|
928
|
+
{ n: Number.POSITIVE_INFINITY },
|
|
929
|
+
{ n: -0 },
|
|
930
|
+
{ n: 2 ** 53 },
|
|
931
|
+
{ n: undefined },
|
|
932
|
+
{ n: () => 1 },
|
|
933
|
+
{ n: 1n },
|
|
934
|
+
{ n: new Date() },
|
|
935
|
+
{ n: new Custom() },
|
|
936
|
+
{ n: new Map() },
|
|
937
|
+
{ n: '\uD800' },
|
|
938
|
+
]) {
|
|
939
|
+
expect(() => computeShareContentRequestHash(value)).toThrow(
|
|
940
|
+
ShareContentCanonicalizationError
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
expect(() => computeShareContentRequestHash(cyclic)).toThrow(
|
|
945
|
+
ShareContentCanonicalizationError
|
|
946
|
+
);
|
|
947
|
+
expect(() => computeShareContentRequestHash(sparse)).toThrow(
|
|
948
|
+
ShareContentCanonicalizationError
|
|
949
|
+
);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it('rejects values nested beyond the depth bound', () => {
|
|
953
|
+
let deep: unknown = 'leaf';
|
|
954
|
+
|
|
955
|
+
for (let index = 0; index < 40; index += 1) deep = { nested: deep };
|
|
956
|
+
|
|
957
|
+
expect(() => computeShareContentRequestHash(deep)).toThrow(
|
|
958
|
+
ShareContentCanonicalizationError
|
|
959
|
+
);
|
|
960
|
+
expect(() => computeShareContentRequestHash(deep, { maxDepth: 100 })).not.toThrow();
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// Real DIDKit signature integration fixture
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
|
|
968
|
+
type DidkitWasm = {
|
|
969
|
+
initSync: (input: { module: Buffer | Uint8Array }) => void;
|
|
970
|
+
generateEd25519KeyFromBytes: (seed: Uint8Array) => string;
|
|
971
|
+
keyToDID: (method: string, jwk: string) => string;
|
|
972
|
+
issuePresentation: (
|
|
973
|
+
presentation: string,
|
|
974
|
+
proofOptions: string,
|
|
975
|
+
key: string,
|
|
976
|
+
context: string
|
|
977
|
+
) => Promise<string>;
|
|
978
|
+
verifyPresentation: (
|
|
979
|
+
presentation: string,
|
|
980
|
+
proofOptions: string,
|
|
981
|
+
context: string
|
|
982
|
+
) => Promise<string>;
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
let didkitPromise: Promise<DidkitWasm> | null = null;
|
|
986
|
+
|
|
987
|
+
const loadDidkit = (): Promise<DidkitWasm> => {
|
|
988
|
+
didkitPromise ??= (async () => {
|
|
989
|
+
const wasmModule =
|
|
990
|
+
(await import('../../../../packages/plugins/didkit/src/didkit/pkg/didkit_wasm.js')) as unknown as DidkitWasm;
|
|
991
|
+
|
|
992
|
+
wasmModule.initSync({
|
|
993
|
+
module: readFileSync(
|
|
994
|
+
new URL(
|
|
995
|
+
'../../../../packages/plugins/didkit/src/didkit/pkg/didkit_wasm_bg.wasm',
|
|
996
|
+
import.meta.url
|
|
997
|
+
)
|
|
998
|
+
),
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
return wasmModule;
|
|
1002
|
+
})();
|
|
1003
|
+
|
|
1004
|
+
return didkitPromise;
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const makeDidkitIdentity = async (fill: number): Promise<{ jwk: string; did: string }> => {
|
|
1008
|
+
const didkit = await loadDidkit();
|
|
1009
|
+
const seed = new Uint8Array(32).fill(fill);
|
|
1010
|
+
const jwk = didkit.generateEd25519KeyFromBytes(seed);
|
|
1011
|
+
|
|
1012
|
+
return { jwk, did: didkit.keyToDID('key', jwk) };
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const issueRealPresentation = async (
|
|
1016
|
+
identity: { jwk: string; did: string },
|
|
1017
|
+
claims: Record<string, unknown>
|
|
1018
|
+
): Promise<string> => {
|
|
1019
|
+
const didkit = await loadDidkit();
|
|
1020
|
+
const presentation = {
|
|
1021
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
1022
|
+
type: ['VerifiablePresentation'],
|
|
1023
|
+
holder: identity.did,
|
|
1024
|
+
verifiableCredential: [],
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
return didkit.issuePresentation(
|
|
1028
|
+
JSON.stringify(presentation),
|
|
1029
|
+
JSON.stringify({
|
|
1030
|
+
proofPurpose: 'authentication',
|
|
1031
|
+
proofFormat: 'jwt',
|
|
1032
|
+
challenge: JSON.stringify(claims),
|
|
1033
|
+
}),
|
|
1034
|
+
identity.jwk,
|
|
1035
|
+
'{}'
|
|
1036
|
+
);
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
const kidOf = (token: string): string => {
|
|
1040
|
+
const [headerPart] = token.split('.');
|
|
1041
|
+
const header = JSON.parse(Buffer.from(headerPart ?? '', 'base64url').toString('utf8'));
|
|
1042
|
+
|
|
1043
|
+
return header.kid as string;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
const realDidkitVerifier = async (token: string): Promise<PresentationVerificationResult> => {
|
|
1047
|
+
const didkit = await loadDidkit();
|
|
1048
|
+
const result = await didkit.verifyPresentation(
|
|
1049
|
+
token,
|
|
1050
|
+
JSON.stringify({ proofFormat: 'jwt' }),
|
|
1051
|
+
'{}'
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
return JSON.parse(result) as PresentationVerificationResult;
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
describe('share-content-auth real DIDKit signature verification', () => {
|
|
1058
|
+
it('accepts a genuine holder-signed VP JWT and rejects a tampered payload', async () => {
|
|
1059
|
+
const brain = await makeDidkitIdentity(3);
|
|
1060
|
+
const token = await issueRealPresentation(brain, baseClaims({ iss: brain.did }));
|
|
1061
|
+
const kid = kidOf(token);
|
|
1062
|
+
|
|
1063
|
+
expect(kid.startsWith(`${brain.did}#`)).toBe(true);
|
|
1064
|
+
|
|
1065
|
+
const config = activeConfig({
|
|
1066
|
+
serviceDids: [brain.did],
|
|
1067
|
+
verificationMethods: [kid],
|
|
1068
|
+
});
|
|
1069
|
+
const { verifier } = makeVerifier({ config, verifyImpl: realDidkitVerifier });
|
|
1070
|
+
|
|
1071
|
+
const accepted = await verifier.authorize(baseRequest(), token);
|
|
1072
|
+
expect(accepted.ok).toBe(true);
|
|
1073
|
+
if (accepted.ok) {
|
|
1074
|
+
expect(accepted.context.signerDid).toBe(brain.did);
|
|
1075
|
+
expect(accepted.context.claims.aud).toBe(AUDIENCE);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Tamper with the signed claims; the real DIDKit verifier must reject the JWS.
|
|
1079
|
+
const [headerPart, payloadPart, signaturePart] = token.split('.');
|
|
1080
|
+
const payload = JSON.parse(Buffer.from(payloadPart ?? '', 'base64url').toString('utf8'));
|
|
1081
|
+
payload.nonce = payload.nonce.replace('"put"', '"delete"');
|
|
1082
|
+
const tampered = `${headerPart}.${Buffer.from(JSON.stringify(payload), 'utf8').toString(
|
|
1083
|
+
'base64url'
|
|
1084
|
+
)}.${signaturePart}`;
|
|
1085
|
+
|
|
1086
|
+
const { verifier: tamperedVerifier } = makeVerifier({
|
|
1087
|
+
config,
|
|
1088
|
+
verifyImpl: realDidkitVerifier,
|
|
1089
|
+
});
|
|
1090
|
+
await expectReject(
|
|
1091
|
+
tamperedVerifier.authorize(baseRequest(), tampered),
|
|
1092
|
+
'SIGNATURE_INVALID'
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('rejects a genuine ordinary user DID before the cryptographic verifier runs', async () => {
|
|
1097
|
+
const brain = await makeDidkitIdentity(3);
|
|
1098
|
+
const user = await makeDidkitIdentity(11);
|
|
1099
|
+
const userToken = await issueRealPresentation(user, baseClaims({ iss: user.did }));
|
|
1100
|
+
const userKid = kidOf(userToken);
|
|
1101
|
+
|
|
1102
|
+
const brainToken = await issueRealPresentation(brain, baseClaims({ iss: brain.did }));
|
|
1103
|
+
const brainKid = kidOf(brainToken);
|
|
1104
|
+
|
|
1105
|
+
const config = activeConfig({
|
|
1106
|
+
serviceDids: [brain.did],
|
|
1107
|
+
verificationMethods: [brainKid],
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
const verify = vi.fn<PresentationVerifier>(realDidkitVerifier);
|
|
1111
|
+
const verifier = createShareContentAuthorizationVerifier({
|
|
1112
|
+
config,
|
|
1113
|
+
verifyPresentation: verify,
|
|
1114
|
+
replayStore: makeAtomicStore().store,
|
|
1115
|
+
now: () => NOW,
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
await expectReject(verifier.authorize(baseRequest(), userToken), 'UNKNOWN_SIGNER');
|
|
1119
|
+
expect(verify).not.toHaveBeenCalled();
|
|
1120
|
+
|
|
1121
|
+
expect(userKid.startsWith(`${user.did}#`)).toBe(true);
|
|
1122
|
+
|
|
1123
|
+
const accepted = await verifier.authorize(baseRequest(), brainToken);
|
|
1124
|
+
expect(accepted.ok).toBe(true);
|
|
1125
|
+
expect(verify).toHaveBeenCalledTimes(1);
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
// Small module-contract smoke checks
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
|
|
1133
|
+
describe('share-content-auth canonical base64url helper', () => {
|
|
1134
|
+
it('accepts canonical ids and rejects alias spellings', () => {
|
|
1135
|
+
expect(isCanonicalBase64Url(SHARE_ID, 16)).toBe(true);
|
|
1136
|
+
expect(isCanonicalBase64Url(SHARE_ID)).toBe(true);
|
|
1137
|
+
expect(isCanonicalBase64Url(`${SHARE_ID}=`)).toBe(false);
|
|
1138
|
+
expect(isCanonicalBase64Url('a')).toBe(false);
|
|
1139
|
+
expect(isCanonicalBase64Url('https://evil.example')).toBe(false);
|
|
1140
|
+
expect(isCanonicalBase64Url(SHARE_ID, 32)).toBe(false);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('exposes a default token byte bound', () => {
|
|
1144
|
+
expect(DEFAULT_MAX_TOKEN_BYTES).toBe(8_192);
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe('strict service JOSE grammar', () => {
|
|
1149
|
+
it.each([
|
|
1150
|
+
{ b64: false },
|
|
1151
|
+
{ customResolver: 'https://untrusted.example' },
|
|
1152
|
+
{ typ: 'unexpected' },
|
|
1153
|
+
])('rejects unsupported headers before verification: %j', async header => {
|
|
1154
|
+
const { verifier, calls } = makeVerifier();
|
|
1155
|
+
expect(await verifier.authorize(baseRequest(), buildToken(baseClaims(), header))).toEqual({
|
|
1156
|
+
ok: false,
|
|
1157
|
+
reason: 'UNSUPPORTED_HEADER',
|
|
1158
|
+
});
|
|
1159
|
+
expect(calls).toHaveLength(0);
|
|
1160
|
+
});
|
|
1161
|
+
});
|