@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.
Files changed (89) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +19 -0
  3. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  4. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  5. package/dist/accesslayer/share-content/index.d.ts +8 -0
  6. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  7. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  8. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  9. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  10. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  11. package/dist/accesslayer/share-content/put.d.ts +5 -0
  12. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  13. package/dist/accesslayer/share-content/read.d.ts +7 -0
  14. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/types.d.ts +85 -0
  16. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  17. package/dist/config/environment.d.ts +12 -0
  18. package/dist/config/environment.d.ts.map +1 -1
  19. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  20. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  21. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  22. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  23. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  24. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  25. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  26. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  27. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  28. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  29. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  30. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  31. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  32. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  33. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  34. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  35. package/dist/index.js +2 -2
  36. package/dist/models/ShareContent.d.ts +160 -0
  37. package/dist/models/ShareContent.d.ts.map +1 -0
  38. package/dist/models/index.d.ts +1 -0
  39. package/dist/models/index.d.ts.map +1 -1
  40. package/dist/share-content/config.d.ts +28 -0
  41. package/dist/share-content/config.d.ts.map +1 -0
  42. package/dist/share-content/index.d.ts +6 -0
  43. package/dist/share-content/index.d.ts.map +1 -0
  44. package/dist/share-content/plugin.d.ts +5 -0
  45. package/dist/share-content/plugin.d.ts.map +1 -0
  46. package/dist/share-content/production.d.ts +15 -0
  47. package/dist/share-content/production.d.ts.map +1 -0
  48. package/dist/share-content/runtime.d.ts +23 -0
  49. package/dist/share-content/runtime.d.ts.map +1 -0
  50. package/dist/share-content/types.d.ts +23 -0
  51. package/dist/share-content/types.d.ts.map +1 -0
  52. package/lambda.ts +61 -0
  53. package/package.json +14 -14
  54. package/serverless.yml +25 -0
  55. package/src/accesslayer/share-content/delete.ts +124 -0
  56. package/src/accesslayer/share-content/index.ts +70 -0
  57. package/src/accesslayer/share-content/initialize.ts +24 -0
  58. package/src/accesslayer/share-content/internal.ts +89 -0
  59. package/src/accesslayer/share-content/put.ts +93 -0
  60. package/src/accesslayer/share-content/read.ts +124 -0
  61. package/src/accesslayer/share-content/types.ts +155 -0
  62. package/src/config/environment.ts +33 -0
  63. package/src/docker-entry.ts +19 -0
  64. package/src/helpers/share-content-auth/adapters.ts +48 -0
  65. package/src/helpers/share-content-auth/canonical.ts +91 -0
  66. package/src/helpers/share-content-auth/claims.ts +164 -0
  67. package/src/helpers/share-content-auth/config.ts +160 -0
  68. package/src/helpers/share-content-auth/index.ts +59 -0
  69. package/src/helpers/share-content-auth/token.ts +148 -0
  70. package/src/helpers/share-content-auth/types.ts +195 -0
  71. package/src/helpers/share-content-auth/verifier.ts +220 -0
  72. package/src/models/ShareContent.ts +198 -0
  73. package/src/models/index.ts +1 -0
  74. package/src/share-content/config.ts +199 -0
  75. package/src/share-content/index.ts +5 -0
  76. package/src/share-content/plugin.ts +317 -0
  77. package/src/share-content/production.ts +107 -0
  78. package/src/share-content/runtime.ts +90 -0
  79. package/src/share-content/types.ts +59 -0
  80. package/test/share-content-auth.spec.ts +1161 -0
  81. package/test/share-content-config.spec.ts +252 -0
  82. package/test/share-content-didkit.spec.ts +411 -0
  83. package/test/share-content-endpoint.spec.ts +825 -0
  84. package/test/share-content-redis-setup.ts +18 -0
  85. package/test/share-content.spec.ts +681 -0
  86. package/vitest.config.ts +1 -0
  87. package/vitest.share-content-config-unit.config.ts +8 -0
  88. package/vitest.share-content-endpoint.config.ts +21 -0
  89. package/vitest.share-content.config.ts +19 -0
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalizeShareContentRequestBody } from '@learncard/helpers';
3
+
4
+ import { SHARE_CONTENT_OPERATIONS, type ShareContentOperation } from './types';
5
+
6
+ /**
7
+ * Canonicalization / identifier primitives for the LC-2187 service auth module.
8
+ *
9
+ * Every value that reaches a hash or a comparison is validated here with strict
10
+ * (not permissive) rules. `Buffer.from(x, 'base64url')` is lenient, so the
11
+ * canonical base64url check re-encodes and requires an exact round trip, which
12
+ * rejects alternative spellings that merely decode to the same bytes.
13
+ */
14
+
15
+ export {
16
+ canonicalizeShareContentRequestBody,
17
+ DEFAULT_MAX_CANONICAL_BYTES as DEFAULT_MAX_CANONICAL_REQUEST_BYTES,
18
+ DEFAULT_MAX_CANONICAL_DEPTH,
19
+ ShareContentCanonicalizationError,
20
+ } from '@learncard/helpers';
21
+
22
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
23
+ const OPAQUE_IDENTIFIER_RE = /^[A-Za-z0-9._~-]+$/;
24
+ const JTI_RE = /^[A-Za-z0-9_-]+$/;
25
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
26
+
27
+ export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
28
+ if (typeof value !== 'object' || value === null) return false;
29
+
30
+ const prototype = Object.getPrototypeOf(value);
31
+
32
+ return prototype === Object.prototype || prototype === null;
33
+ };
34
+
35
+ /** Strict canonical base64url: alphabet, no padding, exact round trip, optional byte length. */
36
+ export const isCanonicalBase64Url = (value: unknown, expectedBytes?: number): value is string => {
37
+ if (typeof value !== 'string' || value.length === 0) return false;
38
+ if (!BASE64URL_RE.test(value)) return false;
39
+
40
+ const decoded = Buffer.from(value, 'base64url');
41
+
42
+ if (decoded.toString('base64url') !== value) return false;
43
+ if (expectedBytes !== undefined && decoded.length !== expectedBytes) return false;
44
+
45
+ return true;
46
+ };
47
+
48
+ export const isSafeContentVersion = (value: unknown): value is number =>
49
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 && value <= 2 ** 31 - 1;
50
+
51
+ export const isSafeEpochSecond = (value: unknown): value is number =>
52
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
53
+
54
+ /**
55
+ * Opaque profile/object/operation/namespace identifier. Deliberately not a UUID
56
+ * check: existing profile ids are opaque URL-safe strings.
57
+ */
58
+ export const isOpaqueIdentifier = (value: unknown, maxLength = 128): value is string =>
59
+ typeof value === 'string' &&
60
+ value.length >= 1 &&
61
+ value.length <= maxLength &&
62
+ OPAQUE_IDENTIFIER_RE.test(value);
63
+
64
+ export const isBoundedNonce = (
65
+ value: unknown,
66
+ minLength: number,
67
+ maxLength: number
68
+ ): value is string =>
69
+ typeof value === 'string' &&
70
+ value.length >= minLength &&
71
+ value.length <= maxLength &&
72
+ JTI_RE.test(value);
73
+
74
+ export const isSha256Hex = (value: unknown): value is string =>
75
+ typeof value === 'string' && SHA256_HEX_RE.test(value);
76
+
77
+ export const isShareContentOperation = (value: unknown): value is ShareContentOperation =>
78
+ typeof value === 'string' && (SHARE_CONTENT_OPERATIONS as readonly string[]).includes(value);
79
+
80
+ /**
81
+ * SHA-256 (lowercase hex) over the canonical UTF-8 request body. This is the
82
+ * value signed into `claims.requestHash`; it must include the opaque ciphertext
83
+ * envelope and the opaque owner-encrypted recovery for `put`.
84
+ */
85
+ export const computeShareContentRequestHash = (
86
+ body: unknown,
87
+ options: { maxBytes?: number; maxDepth?: number } = {}
88
+ ): string =>
89
+ createHash('sha256')
90
+ .update(canonicalizeShareContentRequestBody(body, options), 'utf8')
91
+ .digest('hex');
@@ -0,0 +1,164 @@
1
+ import {
2
+ isBoundedNonce,
3
+ isCanonicalBase64Url,
4
+ isOpaqueIdentifier,
5
+ isPlainObject,
6
+ isSafeContentVersion,
7
+ isSafeEpochSecond,
8
+ isSha256Hex,
9
+ isShareContentOperation,
10
+ } from './canonical';
11
+ import {
12
+ SHARE_CONTENT_AUTH_PURPOSE,
13
+ SHARE_CONTENT_CLAIM_FIELDS,
14
+ type ShareContentAuthorizationClaims,
15
+ type ShareContentTrustConfig,
16
+ } from './types';
17
+
18
+ /** Share ids are 16 CSPRNG bytes → exactly 22 canonical base64url chars. */
19
+ export const SHARE_ID_BYTES = 16;
20
+
21
+ const MAX_OPAQUE_IDENTIFIER_LENGTH = 128;
22
+
23
+ export type SignedClaimsResult =
24
+ { ok: true; raw: unknown } | { ok: false; reason: 'MALFORMED_CLAIMS' };
25
+
26
+ /**
27
+ * Locate the JSON claims the holder signed. DIDKit serializes the presentation
28
+ * proof `challenge` into the JWT `nonce`; some clients surface it under
29
+ * `vp.proof.challenge`. Exactly one location/value must be present.
30
+ */
31
+ export const extractSignedClaims = (payload: Record<string, unknown>): SignedClaimsResult => {
32
+ const nonce = payload.nonce;
33
+ const vp = payload.vp;
34
+
35
+ const proofChallenge =
36
+ isPlainObject(vp) && isPlainObject(vp.proof) ? vp.proof.challenge : undefined;
37
+
38
+ const candidates = [nonce, proofChallenge].filter(candidate => candidate !== undefined);
39
+
40
+ if (candidates.length === 0) return { ok: false, reason: 'MALFORMED_CLAIMS' };
41
+ if (candidates.length === 2 && candidates[0] !== candidates[1]) {
42
+ return { ok: false, reason: 'MALFORMED_CLAIMS' };
43
+ }
44
+
45
+ const challenge = candidates[0];
46
+
47
+ if (typeof challenge !== 'string' || challenge.length === 0) {
48
+ return { ok: false, reason: 'MALFORMED_CLAIMS' };
49
+ }
50
+
51
+ try {
52
+ return { ok: true, raw: JSON.parse(challenge) };
53
+ } catch {
54
+ return { ok: false, reason: 'MALFORMED_CLAIMS' };
55
+ }
56
+ };
57
+
58
+ export type ParseClaimsResult =
59
+ | { ok: true; claims: ShareContentAuthorizationClaims }
60
+ | {
61
+ ok: false;
62
+ reason:
63
+ | 'MALFORMED_CLAIMS'
64
+ | 'UNKNOWN_CLAIM_FIELD'
65
+ | 'INVALID_CLAIMS'
66
+ | 'INVALID_SHARE_ID'
67
+ | 'INVALID_IDENTIFIER'
68
+ | 'INVALID_JTI'
69
+ | 'OPERATION_NOT_ALLOWED'
70
+ | 'PURPOSE_MISMATCH';
71
+ };
72
+
73
+ /**
74
+ * Strict structural validation of the signed claims. Audience/purpose/time and
75
+ * request binding are checked separately by the verifier so each has a distinct
76
+ * rejection reason.
77
+ */
78
+ export const parseShareContentAuthorizationClaims = (
79
+ raw: unknown,
80
+ config: ShareContentTrustConfig
81
+ ): ParseClaimsResult => {
82
+ if (!isPlainObject(raw)) return { ok: false, reason: 'MALFORMED_CLAIMS' };
83
+
84
+ for (const key of Object.keys(raw)) {
85
+ if (!(SHARE_CONTENT_CLAIM_FIELDS as readonly string[]).includes(key)) {
86
+ return { ok: false, reason: 'UNKNOWN_CLAIM_FIELD' };
87
+ }
88
+ }
89
+
90
+ const {
91
+ iss,
92
+ aud,
93
+ purpose,
94
+ namespace,
95
+ op,
96
+ shareId,
97
+ contentVersion,
98
+ ownerProfileId,
99
+ objectId,
100
+ operationId,
101
+ requestHash,
102
+ iat,
103
+ exp,
104
+ jti,
105
+ } = raw;
106
+
107
+ if (typeof iss !== 'string' || iss.length === 0) return { ok: false, reason: 'INVALID_CLAIMS' };
108
+ if (typeof aud !== 'string' || aud.length === 0) return { ok: false, reason: 'INVALID_CLAIMS' };
109
+ if (purpose !== SHARE_CONTENT_AUTH_PURPOSE) return { ok: false, reason: 'PURPOSE_MISMATCH' };
110
+
111
+ if (!isOpaqueIdentifier(namespace, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
112
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
113
+ }
114
+
115
+ if (!isShareContentOperation(op)) return { ok: false, reason: 'OPERATION_NOT_ALLOWED' };
116
+
117
+ if (!isCanonicalBase64Url(shareId, SHARE_ID_BYTES)) {
118
+ return { ok: false, reason: 'INVALID_SHARE_ID' };
119
+ }
120
+
121
+ if (!isSafeContentVersion(contentVersion)) return { ok: false, reason: 'INVALID_CLAIMS' };
122
+
123
+ if (!isOpaqueIdentifier(ownerProfileId, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
124
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
125
+ }
126
+
127
+ if (!isOpaqueIdentifier(objectId, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
128
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
129
+ }
130
+
131
+ if (!isOpaqueIdentifier(operationId, MAX_OPAQUE_IDENTIFIER_LENGTH)) {
132
+ return { ok: false, reason: 'INVALID_IDENTIFIER' };
133
+ }
134
+
135
+ if (!isSha256Hex(requestHash)) return { ok: false, reason: 'INVALID_CLAIMS' };
136
+
137
+ if (!isSafeEpochSecond(iat) || !isSafeEpochSecond(exp)) {
138
+ return { ok: false, reason: 'INVALID_CLAIMS' };
139
+ }
140
+
141
+ if (!isBoundedNonce(jti, config.minJtiLength, config.maxJtiLength)) {
142
+ return { ok: false, reason: 'INVALID_JTI' };
143
+ }
144
+
145
+ return {
146
+ ok: true,
147
+ claims: {
148
+ iss,
149
+ aud,
150
+ purpose: SHARE_CONTENT_AUTH_PURPOSE,
151
+ namespace,
152
+ op,
153
+ shareId,
154
+ contentVersion,
155
+ ownerProfileId,
156
+ objectId,
157
+ operationId,
158
+ requestHash,
159
+ iat,
160
+ exp,
161
+ jti,
162
+ },
163
+ };
164
+ };
@@ -0,0 +1,160 @@
1
+ import type { ShareContentTrustConfig } from './types';
2
+
3
+ /**
4
+ * Build the trust configuration from an explicit, caller-supplied object.
5
+ *
6
+ * Nothing here reads `process.env`, secrets or a bootstrap file: the batch that
7
+ * owns environment wiring must construct this deliberately and pass it in. Any
8
+ * missing or invalid value when `enabled` is true collapses to the disabled
9
+ * configuration so access fails closed rather than partially.
10
+ */
11
+
12
+ export const HARD_MAX_TOKEN_TTL_SECONDS = 60;
13
+ export const HARD_MAX_CLOCK_SKEW_SECONDS = 60;
14
+ export const HARD_MIN_TOKEN_BYTES = 256;
15
+ export const HARD_MAX_TOKEN_BYTES = 65_536;
16
+ export const DEFAULT_MAX_TOKEN_BYTES = 8_192;
17
+ export const DEFAULT_MIN_JTI_LENGTH = 16;
18
+ export const DEFAULT_MAX_JTI_LENGTH = 128;
19
+
20
+ export const DISABLED_SHARE_CONTENT_TRUST_CONFIG: ShareContentTrustConfig = Object.freeze({
21
+ enabled: false,
22
+ audience: '',
23
+ allowedServiceDids: Object.freeze([]) as readonly string[],
24
+ allowedVerificationMethods: Object.freeze([]) as readonly string[],
25
+ allowedAlgorithms: Object.freeze(['EdDSA']) as readonly string[],
26
+ clockSkewSeconds: 5,
27
+ maxTokenTtlSeconds: HARD_MAX_TOKEN_TTL_SECONDS,
28
+ maxTokenBytes: DEFAULT_MAX_TOKEN_BYTES,
29
+ minJtiLength: DEFAULT_MIN_JTI_LENGTH,
30
+ maxJtiLength: DEFAULT_MAX_JTI_LENGTH,
31
+ });
32
+
33
+ const toStringList = (value: unknown): string[] | null => {
34
+ if (typeof value === 'string') {
35
+ const parts = value
36
+ .split(',')
37
+ .map(part => part.trim())
38
+ .filter(part => part.length > 0);
39
+
40
+ return parts;
41
+ }
42
+
43
+ if (Array.isArray(value)) {
44
+ if (!value.every(item => typeof item === 'string')) return null;
45
+
46
+ const parts = (value as string[]).map(part => part.trim()).filter(part => part.length > 0);
47
+
48
+ return parts;
49
+ }
50
+
51
+ return null;
52
+ };
53
+
54
+ const readBoolean = (value: unknown): boolean => value === true;
55
+
56
+ const readBoundedInteger = (
57
+ value: unknown,
58
+ fallback: number,
59
+ minimum: number,
60
+ maximum: number
61
+ ): number | null => {
62
+ if (value === undefined) return fallback;
63
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) return null;
64
+ if (value < minimum || value > maximum) return null;
65
+
66
+ return value;
67
+ };
68
+
69
+ /**
70
+ * Resolve explicit configuration. Returns the frozen, fully-validated config or
71
+ * the disabled config. An empty allowlist, empty audience or `enabled: false`
72
+ * disables all access.
73
+ */
74
+ export const resolveShareContentTrustConfig = (raw: unknown): ShareContentTrustConfig => {
75
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
76
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
77
+ }
78
+
79
+ const source = raw as Record<string, unknown>;
80
+
81
+ if (!readBoolean(source.enabled)) return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
82
+
83
+ const audience = source.audience;
84
+ if (typeof audience !== 'string' || audience.trim().length === 0) {
85
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
86
+ }
87
+
88
+ const serviceDids = toStringList(source.serviceDids);
89
+ const verificationMethods = toStringList(source.verificationMethods);
90
+
91
+ if (!serviceDids || serviceDids.length === 0) return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
92
+ if (!verificationMethods || verificationMethods.length === 0) {
93
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
94
+ }
95
+
96
+ const allowedAlgorithms = toStringList(source.allowedAlgorithms) ?? ['EdDSA'];
97
+ if (allowedAlgorithms.length === 0) return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
98
+
99
+ const clockSkewSeconds = readBoundedInteger(
100
+ source.clockSkewSeconds,
101
+ DISABLED_SHARE_CONTENT_TRUST_CONFIG.clockSkewSeconds,
102
+ 0,
103
+ HARD_MAX_CLOCK_SKEW_SECONDS
104
+ );
105
+ const maxTokenTtlSeconds = readBoundedInteger(
106
+ source.maxTokenTtlSeconds,
107
+ HARD_MAX_TOKEN_TTL_SECONDS,
108
+ 1,
109
+ HARD_MAX_TOKEN_TTL_SECONDS
110
+ );
111
+ const maxTokenBytes = readBoundedInteger(
112
+ source.maxTokenBytes,
113
+ DEFAULT_MAX_TOKEN_BYTES,
114
+ HARD_MIN_TOKEN_BYTES,
115
+ HARD_MAX_TOKEN_BYTES
116
+ );
117
+ const minJtiLength = readBoundedInteger(
118
+ source.minJtiLength,
119
+ DEFAULT_MIN_JTI_LENGTH,
120
+ 8,
121
+ DEFAULT_MAX_JTI_LENGTH
122
+ );
123
+ const maxJtiLength = readBoundedInteger(
124
+ source.maxJtiLength,
125
+ DEFAULT_MAX_JTI_LENGTH,
126
+ DEFAULT_MIN_JTI_LENGTH,
127
+ 512
128
+ );
129
+
130
+ if (
131
+ clockSkewSeconds === null ||
132
+ maxTokenTtlSeconds === null ||
133
+ maxTokenBytes === null ||
134
+ minJtiLength === null ||
135
+ maxJtiLength === null ||
136
+ minJtiLength > maxJtiLength
137
+ ) {
138
+ return DISABLED_SHARE_CONTENT_TRUST_CONFIG;
139
+ }
140
+
141
+ return Object.freeze({
142
+ enabled: true,
143
+ audience,
144
+ allowedServiceDids: Object.freeze([...serviceDids]),
145
+ allowedVerificationMethods: Object.freeze([...verificationMethods]),
146
+ allowedAlgorithms: Object.freeze([...allowedAlgorithms]),
147
+ clockSkewSeconds,
148
+ maxTokenTtlSeconds,
149
+ maxTokenBytes,
150
+ minJtiLength,
151
+ maxJtiLength,
152
+ });
153
+ };
154
+
155
+ /** A config only authorizes traffic when it is enabled and its allowlists are non-empty. */
156
+ export const isShareContentTrustConfigActive = (config: ShareContentTrustConfig): boolean =>
157
+ config.enabled &&
158
+ config.audience.length > 0 &&
159
+ config.allowedServiceDids.length > 0 &&
160
+ config.allowedVerificationMethods.length > 0;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * LC-2187 robust multi-credential sharing — LearnCloud service authorization.
3
+ *
4
+ * A narrow, reusable verifier foundation for the future service-only
5
+ * `share_content` storage routes (put/get/stat/delete of exact immutable
6
+ * objects plus the separate owner-recovery read intent). It is **not** wired to
7
+ * any route or server entrypoint and reads no environment/secrets; callers
8
+ * inject the trust configuration, DIDKit verification, clock and atomic replay
9
+ * store.
10
+ *
11
+ * No plaintext keys, credentials or source URIs are handled here: the request
12
+ * hash covers the opaque ciphertext envelope and the opaque owner-encrypted
13
+ * recovery.
14
+ */
15
+
16
+ export * from './types';
17
+ export {
18
+ SHARE_ID_BYTES,
19
+ extractSignedClaims,
20
+ parseShareContentAuthorizationClaims,
21
+ } from './claims';
22
+ export {
23
+ DEFAULT_MAX_CANONICAL_DEPTH,
24
+ DEFAULT_MAX_CANONICAL_REQUEST_BYTES,
25
+ ShareContentCanonicalizationError,
26
+ canonicalizeShareContentRequestBody,
27
+ computeShareContentRequestHash,
28
+ isBoundedNonce,
29
+ isCanonicalBase64Url,
30
+ isOpaqueIdentifier,
31
+ isPlainObject,
32
+ isSafeContentVersion,
33
+ isSafeEpochSecond,
34
+ isSha256Hex,
35
+ isShareContentOperation,
36
+ } from './canonical';
37
+ export {
38
+ DEFAULT_MAX_JTI_LENGTH,
39
+ DEFAULT_MAX_TOKEN_BYTES,
40
+ DEFAULT_MIN_JTI_LENGTH,
41
+ DISABLED_SHARE_CONTENT_TRUST_CONFIG,
42
+ HARD_MAX_CLOCK_SKEW_SECONDS,
43
+ HARD_MAX_TOKEN_BYTES,
44
+ HARD_MAX_TOKEN_TTL_SECONDS,
45
+ HARD_MIN_TOKEN_BYTES,
46
+ isShareContentTrustConfigActive,
47
+ resolveShareContentTrustConfig,
48
+ } from './config';
49
+ export { parseCompactJwt, validateShareContentJwtHeader } from './token';
50
+ export {
51
+ createShareContentAuthorizationVerifier,
52
+ type CreateShareContentAuthorizationVerifierDependencies,
53
+ } from './verifier';
54
+ export {
55
+ createDidkitPresentationVerifier,
56
+ createSetIfAbsentReplayStore,
57
+ type LearnCardLike,
58
+ type SetIfAbsent,
59
+ } from './adapters';
@@ -0,0 +1,148 @@
1
+ import { isCanonicalBase64Url, isPlainObject } from './canonical';
2
+ import type { ShareContentTrustConfig } from './types';
3
+
4
+ /**
5
+ * Strict, bounded parsing of the compact JWS produced by DIDKit for a
6
+ * holder-signed Verifiable Presentation. Nothing here is trusted: parsing only
7
+ * establishes syntactic shape and the allowlisted signer before any resolver or
8
+ * signature verifier is invoked.
9
+ */
10
+
11
+ /** Remote-key and extension headers are never valid for this purpose. */
12
+ const FORBIDDEN_HEADER_FIELDS = ['jwk', 'jku', 'x5u', 'x5c', 'crit', 'enc', 'apu', 'apv'] as const;
13
+
14
+ const MAX_KID_LENGTH = 512;
15
+
16
+ export type ParsedCompactJwt = {
17
+ header: Record<string, unknown>;
18
+ payload: Record<string, unknown>;
19
+ signingInput: string;
20
+ signature: string;
21
+ };
22
+
23
+ export type ParseCompactJwtResult =
24
+ | { ok: true; parsed: ParsedCompactJwt }
25
+ | { ok: false; reason: 'TOKEN_TOO_LARGE' | 'MALFORMED_TOKEN' };
26
+
27
+ export const parseCompactJwt = (token: unknown, maxBytes: number): ParseCompactJwtResult => {
28
+ if (typeof token !== 'string') return { ok: false, reason: 'MALFORMED_TOKEN' };
29
+ if (Buffer.byteLength(token, 'utf8') > maxBytes)
30
+ return { ok: false, reason: 'TOKEN_TOO_LARGE' };
31
+
32
+ const parts = token.split('.');
33
+
34
+ if (parts.length !== 3) return { ok: false, reason: 'MALFORMED_TOKEN' };
35
+
36
+ const [headerPart, payloadPart, signaturePart] = parts;
37
+
38
+ if (!headerPart || !payloadPart || !signaturePart) {
39
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
40
+ }
41
+
42
+ if (
43
+ !isCanonicalBase64Url(headerPart) ||
44
+ !isCanonicalBase64Url(payloadPart) ||
45
+ !isCanonicalBase64Url(signaturePart)
46
+ ) {
47
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
48
+ }
49
+
50
+ let header: unknown;
51
+ let payload: unknown;
52
+
53
+ try {
54
+ header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8'));
55
+ payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
56
+ } catch {
57
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
58
+ }
59
+
60
+ if (!isPlainObject(header) || !isPlainObject(payload)) {
61
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
62
+ }
63
+
64
+ return {
65
+ ok: true,
66
+ parsed: {
67
+ header,
68
+ payload,
69
+ signingInput: `${headerPart}.${payloadPart}`,
70
+ signature: signaturePart,
71
+ },
72
+ };
73
+ };
74
+
75
+ export type ValidatedJwtHeader = {
76
+ signerDid: string;
77
+ /** Full `kid`, e.g. `did:web:brain.example#key-1`. */
78
+ verificationMethod: string;
79
+ };
80
+
81
+ export type ValidateJwtHeaderResult =
82
+ | { ok: true; value: ValidatedJwtHeader }
83
+ | {
84
+ ok: false;
85
+ reason:
86
+ | 'UNSUPPORTED_HEADER'
87
+ | 'UNSUPPORTED_ALGORITHM'
88
+ | 'MISSING_KID'
89
+ | 'UNKNOWN_SIGNER'
90
+ | 'UNKNOWN_VERIFICATION_METHOD';
91
+ };
92
+
93
+ /**
94
+ * Validate the JOSE header and apply the exact DID + verification-method
95
+ * allowlist. This runs *before* the injected signature verifier, so an unknown
96
+ * `kid` can never trigger did:web/did:key network resolution.
97
+ */
98
+ export const validateShareContentJwtHeader = (
99
+ header: Record<string, unknown>,
100
+ config: ShareContentTrustConfig
101
+ ): ValidateJwtHeaderResult => {
102
+ if (
103
+ Object.keys(header).some(field => !['alg', 'kid', 'typ'].includes(field)) ||
104
+ (header.typ !== undefined && header.typ !== 'JWT')
105
+ ) {
106
+ return { ok: false, reason: 'UNSUPPORTED_HEADER' };
107
+ }
108
+ for (const field of FORBIDDEN_HEADER_FIELDS) {
109
+ if (field in header) return { ok: false, reason: 'UNSUPPORTED_HEADER' };
110
+ }
111
+
112
+ const alg = header.alg;
113
+
114
+ if (typeof alg !== 'string' || !config.allowedAlgorithms.includes(alg)) {
115
+ return { ok: false, reason: 'UNSUPPORTED_ALGORITHM' };
116
+ }
117
+
118
+ const kid = header.kid;
119
+
120
+ if (typeof kid !== 'string' || kid.length === 0 || kid.length > MAX_KID_LENGTH) {
121
+ return { ok: false, reason: 'MISSING_KID' };
122
+ }
123
+
124
+ // Reject URLs / relative key references outright. Only a DID URL fragment is valid.
125
+ if (/^[a-z][a-z0-9+.-]*:/i.test(kid) && !kid.startsWith('did:')) {
126
+ return { ok: false, reason: 'UNSUPPORTED_HEADER' };
127
+ }
128
+
129
+ const hashIndex = kid.indexOf('#');
130
+
131
+ if (hashIndex <= 0 || hashIndex === kid.length - 1) {
132
+ return { ok: false, reason: 'MISSING_KID' };
133
+ }
134
+
135
+ const signerDid = kid.slice(0, hashIndex);
136
+
137
+ if (!signerDid.startsWith('did:')) return { ok: false, reason: 'MISSING_KID' };
138
+
139
+ if (!config.allowedServiceDids.includes(signerDid)) {
140
+ return { ok: false, reason: 'UNKNOWN_SIGNER' };
141
+ }
142
+
143
+ if (!config.allowedVerificationMethods.includes(kid)) {
144
+ return { ok: false, reason: 'UNKNOWN_VERIFICATION_METHOD' };
145
+ }
146
+
147
+ return { ok: true, value: { signerDid, verificationMethod: kid } };
148
+ };