@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,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
|
+
};
|