@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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-2187 — Brain → LearnCloud share-content service authorization types.
|
|
3
|
+
*
|
|
4
|
+
* This module is a reusable authorization *verifier foundation*. It is not
|
|
5
|
+
* registered on any router and it does not read the environment. Callers inject
|
|
6
|
+
* the trust configuration, the presentation verifier, the clock and the atomic
|
|
7
|
+
* replay store, so the unit tests exercise the real module without Redis,
|
|
8
|
+
* MongoDB, did:web resolution or unfinished storage/schema work.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Signed purpose claim. V1 is the only accepted value. */
|
|
12
|
+
export const SHARE_CONTENT_AUTH_PURPOSE = 'lc-share-content/v1' as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Least-privilege operation allowlist. There is deliberately no `list`,
|
|
16
|
+
* `deleteOwner` or other wildcard/collection-wide privilege: every token is
|
|
17
|
+
* bound to exactly one immutable object. `readRecovery` is the separate
|
|
18
|
+
* owner-encrypted recovery read intent.
|
|
19
|
+
*/
|
|
20
|
+
export const SHARE_CONTENT_OPERATIONS = ['put', 'get', 'stat', 'delete', 'readRecovery'] as const;
|
|
21
|
+
|
|
22
|
+
export type ShareContentOperation = (typeof SHARE_CONTENT_OPERATIONS)[number];
|
|
23
|
+
|
|
24
|
+
/** Every key that may appear in the signed claims object. Unknown keys are rejected. */
|
|
25
|
+
export const SHARE_CONTENT_CLAIM_FIELDS = [
|
|
26
|
+
'iss',
|
|
27
|
+
'aud',
|
|
28
|
+
'purpose',
|
|
29
|
+
'namespace',
|
|
30
|
+
'op',
|
|
31
|
+
'shareId',
|
|
32
|
+
'contentVersion',
|
|
33
|
+
'ownerProfileId',
|
|
34
|
+
'objectId',
|
|
35
|
+
'operationId',
|
|
36
|
+
'requestHash',
|
|
37
|
+
'iat',
|
|
38
|
+
'exp',
|
|
39
|
+
'jti',
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type ShareContentClaimField = (typeof SHARE_CONTENT_CLAIM_FIELDS)[number];
|
|
43
|
+
|
|
44
|
+
/** Claims signed by Brain and re-validated by LearnCloud. */
|
|
45
|
+
export type ShareContentAuthorizationClaims = {
|
|
46
|
+
/** Issuer = holder = the allowlisted Brain service DID. */
|
|
47
|
+
iss: string;
|
|
48
|
+
/** LearnCloud service DID. */
|
|
49
|
+
aud: string;
|
|
50
|
+
purpose: typeof SHARE_CONTENT_AUTH_PURPOSE;
|
|
51
|
+
/** Service/network namespace (tenant or deployment scope). */
|
|
52
|
+
namespace: string;
|
|
53
|
+
op: ShareContentOperation;
|
|
54
|
+
/** 16-byte canonical base64url share id (22 chars). */
|
|
55
|
+
shareId: string;
|
|
56
|
+
contentVersion: number;
|
|
57
|
+
/** Opaque profile id. Existing ids are opaque strings, not necessarily UUIDs. */
|
|
58
|
+
ownerProfileId: string;
|
|
59
|
+
/** Immutable object identity for this exact content version. */
|
|
60
|
+
objectId: string;
|
|
61
|
+
/** Operation/idempotency identity. */
|
|
62
|
+
operationId: string;
|
|
63
|
+
/** Lowercase hex SHA-256 of the canonical full request body, incl. opaque recovery. */
|
|
64
|
+
requestHash: string;
|
|
65
|
+
iat: number;
|
|
66
|
+
exp: number;
|
|
67
|
+
/** CSPRNG nonce; single-use. */
|
|
68
|
+
jti: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The exact request context the token must be bound to. Callers build this from
|
|
73
|
+
* the validated route input; `requestHash` is produced with
|
|
74
|
+
* `computeShareContentRequestHash`.
|
|
75
|
+
*/
|
|
76
|
+
export type ShareContentAuthorizationRequest = {
|
|
77
|
+
namespace: string;
|
|
78
|
+
op: ShareContentOperation;
|
|
79
|
+
shareId: string;
|
|
80
|
+
contentVersion: number;
|
|
81
|
+
ownerProfileId: string;
|
|
82
|
+
objectId: string;
|
|
83
|
+
operationId: string;
|
|
84
|
+
requestHash: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Minimal authenticated context returned on success. Never contains the raw token. */
|
|
88
|
+
export type ShareContentAuthorizationContext = {
|
|
89
|
+
signerDid: string;
|
|
90
|
+
/** Full `kid` verification method that signed the presentation. */
|
|
91
|
+
verificationMethod: string;
|
|
92
|
+
claims: ShareContentAuthorizationClaims;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Result shape of the existing DIDKit `verifyPresentation(..., { proofFormat: 'jwt' })`. */
|
|
96
|
+
export type PresentationVerificationResult = {
|
|
97
|
+
checks: string[];
|
|
98
|
+
warnings: string[];
|
|
99
|
+
errors: string[];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Injected DIDKit adapter. The module never resolves keys itself. */
|
|
103
|
+
export type PresentationVerifier = (token: string) => Promise<PresentationVerificationResult>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Injected atomic replay store. `consumeOnce` must be a single atomic
|
|
107
|
+
* SET-if-absent (e.g. Redis `SET key value NX EX ttl`) and must throw when the
|
|
108
|
+
* backing store is unavailable so the verifier can fail closed.
|
|
109
|
+
*/
|
|
110
|
+
export type ReplayStore = {
|
|
111
|
+
consumeOnce: (key: string, ttlSeconds: number) => Promise<boolean>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Explicit trust configuration. An empty/disabled configuration denies all
|
|
116
|
+
* access (fail closed). There is deliberately no offline bypass flag.
|
|
117
|
+
*
|
|
118
|
+
* Key rotation assumptions (document-backed, not yet integration-tested):
|
|
119
|
+
* - A `did:web` identifier is stable across signing-seed rotation; the
|
|
120
|
+
* verification method fragment in the JWT `kid` is what changes.
|
|
121
|
+
* - Operators must add the new verification method to
|
|
122
|
+
* `allowedVerificationMethods` *before* the new key signs, and keep the old
|
|
123
|
+
* entry for at least `maxTokenTtlSeconds + 2 * clockSkewSeconds` after the
|
|
124
|
+
* last token signed by the old key.
|
|
125
|
+
* - did:web DID-document/resolver cache propagation must be validated in the
|
|
126
|
+
* integration batch; this module never resolves a DID itself.
|
|
127
|
+
*/
|
|
128
|
+
export type ShareContentTrustConfig = {
|
|
129
|
+
enabled: boolean;
|
|
130
|
+
/** LearnCloud's own service DID. Must be non-empty when enabled. */
|
|
131
|
+
audience: string;
|
|
132
|
+
/** Exact allowed Brain service DIDs. A user DID must never be listed. */
|
|
133
|
+
allowedServiceDids: readonly string[];
|
|
134
|
+
/**
|
|
135
|
+
* Exact allowed verification methods (full `kid` values). Required: a DID
|
|
136
|
+
* alone does not authorize a key. Empty disables access.
|
|
137
|
+
*/
|
|
138
|
+
allowedVerificationMethods: readonly string[];
|
|
139
|
+
/** Accepted JWS algorithms. Defaults to `EdDSA` only. */
|
|
140
|
+
allowedAlgorithms: readonly string[];
|
|
141
|
+
/** Bounded clock skew, seconds. */
|
|
142
|
+
clockSkewSeconds: number;
|
|
143
|
+
/** Maximum signed lifetime (`exp - iat`), seconds. Never above 60. */
|
|
144
|
+
maxTokenTtlSeconds: number;
|
|
145
|
+
/** Maximum raw token size in bytes. */
|
|
146
|
+
maxTokenBytes: number;
|
|
147
|
+
minJtiLength: number;
|
|
148
|
+
maxJtiLength: number;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Rejection reasons are stable, non-secret codes. Routes must map every one of
|
|
153
|
+
* them to a single generic external rejection; they must never be logged
|
|
154
|
+
* alongside token or claim payloads.
|
|
155
|
+
*/
|
|
156
|
+
export type ShareContentAuthorizationRejectionReason =
|
|
157
|
+
| 'DISABLED'
|
|
158
|
+
| 'INVALID_REQUEST'
|
|
159
|
+
| 'TOKEN_TOO_LARGE'
|
|
160
|
+
| 'MALFORMED_TOKEN'
|
|
161
|
+
| 'UNSUPPORTED_HEADER'
|
|
162
|
+
| 'UNSUPPORTED_ALGORITHM'
|
|
163
|
+
| 'MISSING_KID'
|
|
164
|
+
| 'UNKNOWN_SIGNER'
|
|
165
|
+
| 'UNKNOWN_VERIFICATION_METHOD'
|
|
166
|
+
| 'SIGNATURE_INVALID'
|
|
167
|
+
| 'SIGNER_MISMATCH'
|
|
168
|
+
| 'MALFORMED_CLAIMS'
|
|
169
|
+
| 'UNKNOWN_CLAIM_FIELD'
|
|
170
|
+
| 'INVALID_CLAIMS'
|
|
171
|
+
| 'INVALID_SHARE_ID'
|
|
172
|
+
| 'INVALID_IDENTIFIER'
|
|
173
|
+
| 'INVALID_JTI'
|
|
174
|
+
| 'AUDIENCE_MISMATCH'
|
|
175
|
+
| 'PURPOSE_MISMATCH'
|
|
176
|
+
| 'OPERATION_NOT_ALLOWED'
|
|
177
|
+
| 'INVALID_TTL'
|
|
178
|
+
| 'TOKEN_EXPIRED'
|
|
179
|
+
| 'TOKEN_NOT_YET_VALID'
|
|
180
|
+
| 'REQUEST_BINDING_MISMATCH'
|
|
181
|
+
| 'REQUEST_HASH_MISMATCH'
|
|
182
|
+
| 'REPLAY_DETECTED'
|
|
183
|
+
| 'REPLAY_STORE_UNAVAILABLE';
|
|
184
|
+
|
|
185
|
+
export type ShareContentAuthorizationResult =
|
|
186
|
+
| { ok: true; context: ShareContentAuthorizationContext }
|
|
187
|
+
| { ok: false; reason: ShareContentAuthorizationRejectionReason };
|
|
188
|
+
|
|
189
|
+
export type ShareContentAuthorizationVerifier = {
|
|
190
|
+
config: ShareContentTrustConfig;
|
|
191
|
+
authorize: (
|
|
192
|
+
request: ShareContentAuthorizationRequest,
|
|
193
|
+
token: string
|
|
194
|
+
) => Promise<ShareContentAuthorizationResult>;
|
|
195
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isCanonicalBase64Url,
|
|
5
|
+
isOpaqueIdentifier,
|
|
6
|
+
isPlainObject,
|
|
7
|
+
isSafeContentVersion,
|
|
8
|
+
isSha256Hex,
|
|
9
|
+
isShareContentOperation,
|
|
10
|
+
} from './canonical';
|
|
11
|
+
import {
|
|
12
|
+
SHARE_ID_BYTES,
|
|
13
|
+
extractSignedClaims,
|
|
14
|
+
parseShareContentAuthorizationClaims,
|
|
15
|
+
} from './claims';
|
|
16
|
+
import { isShareContentTrustConfigActive } from './config';
|
|
17
|
+
import { parseCompactJwt, validateShareContentJwtHeader } from './token';
|
|
18
|
+
import {
|
|
19
|
+
SHARE_CONTENT_AUTH_PURPOSE,
|
|
20
|
+
type PresentationVerificationResult,
|
|
21
|
+
type PresentationVerifier,
|
|
22
|
+
type ReplayStore,
|
|
23
|
+
type ShareContentAuthorizationContext,
|
|
24
|
+
type ShareContentAuthorizationRequest,
|
|
25
|
+
type ShareContentAuthorizationResult,
|
|
26
|
+
type ShareContentAuthorizationVerifier,
|
|
27
|
+
type ShareContentTrustConfig,
|
|
28
|
+
} from './types';
|
|
29
|
+
|
|
30
|
+
export type CreateShareContentAuthorizationVerifierDependencies = {
|
|
31
|
+
config: ShareContentTrustConfig;
|
|
32
|
+
/** Injected DIDKit presentation verification. Never called before allowlist filtering. */
|
|
33
|
+
verifyPresentation: PresentationVerifier;
|
|
34
|
+
/** Injected atomic single-use replay store. */
|
|
35
|
+
replayStore: ReplayStore;
|
|
36
|
+
/** Injected epoch-seconds clock. Defaults to `Date.now()`. */
|
|
37
|
+
now?: () => number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isValidAuthorizationRequest = (
|
|
41
|
+
request: unknown
|
|
42
|
+
): request is ShareContentAuthorizationRequest => {
|
|
43
|
+
if (!isPlainObject(request)) return false;
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
namespace,
|
|
47
|
+
op,
|
|
48
|
+
shareId,
|
|
49
|
+
contentVersion,
|
|
50
|
+
ownerProfileId,
|
|
51
|
+
objectId,
|
|
52
|
+
operationId,
|
|
53
|
+
requestHash,
|
|
54
|
+
} = request;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
isOpaqueIdentifier(namespace) &&
|
|
58
|
+
isShareContentOperation(op) &&
|
|
59
|
+
isCanonicalBase64Url(shareId, SHARE_ID_BYTES) &&
|
|
60
|
+
isSafeContentVersion(contentVersion) &&
|
|
61
|
+
isOpaqueIdentifier(ownerProfileId) &&
|
|
62
|
+
isOpaqueIdentifier(objectId) &&
|
|
63
|
+
isOpaqueIdentifier(operationId) &&
|
|
64
|
+
isSha256Hex(requestHash)
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const isSignatureValid = (verification: PresentationVerificationResult | undefined): boolean =>
|
|
69
|
+
Boolean(
|
|
70
|
+
verification &&
|
|
71
|
+
Array.isArray(verification.errors) &&
|
|
72
|
+
Array.isArray(verification.checks) &&
|
|
73
|
+
verification.errors.length === 0 &&
|
|
74
|
+
verification.checks.includes('JWS')
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build an authorization verifier over a fixed trust configuration.
|
|
79
|
+
*
|
|
80
|
+
* The returned `authorize(request, token)` is the only public entry point. It:
|
|
81
|
+
* 1. denies everything when the configuration is empty/disabled;
|
|
82
|
+
* 2. validates the expected request context;
|
|
83
|
+
* 3. strictly parses the bounded JWT and applies the exact DID + key-method
|
|
84
|
+
* allowlist *before* calling the signature verifier;
|
|
85
|
+
* 4. cryptographically verifies, then re-binds kid → signer DID → `iss` →
|
|
86
|
+
* `vp.holder` → signed `claims.iss`;
|
|
87
|
+
* 5. validates purpose/audience/operation/time/nonce and exact request
|
|
88
|
+
* binding (including the canonical body hash);
|
|
89
|
+
* 6. atomically consumes the nonce for the full remaining acceptance window,
|
|
90
|
+
* failing closed if the replay store errors.
|
|
91
|
+
*
|
|
92
|
+
* A fresh signed token (new `jti`) may retry the same idempotent operation;
|
|
93
|
+
* request idempotency is a separate concern owned by the storage layer.
|
|
94
|
+
*/
|
|
95
|
+
export const createShareContentAuthorizationVerifier = (
|
|
96
|
+
dependencies: CreateShareContentAuthorizationVerifierDependencies
|
|
97
|
+
): ShareContentAuthorizationVerifier => {
|
|
98
|
+
const { config, verifyPresentation, replayStore } = dependencies;
|
|
99
|
+
const now = dependencies.now ?? (() => Math.floor(Date.now() / 1000));
|
|
100
|
+
|
|
101
|
+
const authorize = async (
|
|
102
|
+
request: ShareContentAuthorizationRequest,
|
|
103
|
+
token: string
|
|
104
|
+
): Promise<ShareContentAuthorizationResult> => {
|
|
105
|
+
if (!isShareContentTrustConfigActive(config)) return { ok: false, reason: 'DISABLED' };
|
|
106
|
+
if (!isValidAuthorizationRequest(request)) return { ok: false, reason: 'INVALID_REQUEST' };
|
|
107
|
+
|
|
108
|
+
const parseResult = parseCompactJwt(token, config.maxTokenBytes);
|
|
109
|
+
if (!parseResult.ok) return parseResult;
|
|
110
|
+
|
|
111
|
+
const headerResult = validateShareContentJwtHeader(parseResult.parsed.header, config);
|
|
112
|
+
if (!headerResult.ok) return headerResult;
|
|
113
|
+
|
|
114
|
+
const { signerDid, verificationMethod } = headerResult.value;
|
|
115
|
+
|
|
116
|
+
let verification: PresentationVerificationResult;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
verification = await verifyPresentation(token);
|
|
120
|
+
} catch {
|
|
121
|
+
return { ok: false, reason: 'SIGNATURE_INVALID' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isSignatureValid(verification)) return { ok: false, reason: 'SIGNATURE_INVALID' };
|
|
125
|
+
|
|
126
|
+
const { payload } = parseResult.parsed;
|
|
127
|
+
|
|
128
|
+
// Recheck every identity claim against the key that actually signed,
|
|
129
|
+
// after cryptographic verification. Parsing alone never authenticates.
|
|
130
|
+
if (payload.iss !== signerDid) return { ok: false, reason: 'SIGNER_MISMATCH' };
|
|
131
|
+
|
|
132
|
+
const vp = payload.vp;
|
|
133
|
+
if (!isPlainObject(vp) || vp.holder !== signerDid) {
|
|
134
|
+
return { ok: false, reason: 'SIGNER_MISMATCH' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const claimsResult = extractSignedClaims(payload);
|
|
138
|
+
if (!claimsResult.ok) return claimsResult;
|
|
139
|
+
|
|
140
|
+
const parsedClaims = parseShareContentAuthorizationClaims(claimsResult.raw, config);
|
|
141
|
+
if (!parsedClaims.ok) return parsedClaims;
|
|
142
|
+
|
|
143
|
+
const claims = parsedClaims.claims;
|
|
144
|
+
|
|
145
|
+
if (claims.iss !== signerDid) return { ok: false, reason: 'SIGNER_MISMATCH' };
|
|
146
|
+
if (claims.aud !== config.audience) return { ok: false, reason: 'AUDIENCE_MISMATCH' };
|
|
147
|
+
if (claims.purpose !== SHARE_CONTENT_AUTH_PURPOSE) {
|
|
148
|
+
return { ok: false, reason: 'PURPOSE_MISMATCH' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const currentTime = now();
|
|
152
|
+
if (!Number.isSafeInteger(currentTime)) return { ok: false, reason: 'INVALID_TTL' };
|
|
153
|
+
|
|
154
|
+
const signedTtl = claims.exp - claims.iat;
|
|
155
|
+
if (signedTtl <= 0 || signedTtl > config.maxTokenTtlSeconds) {
|
|
156
|
+
return { ok: false, reason: 'INVALID_TTL' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { clockSkewSeconds } = config;
|
|
160
|
+
|
|
161
|
+
if (currentTime > claims.exp + clockSkewSeconds)
|
|
162
|
+
return { ok: false, reason: 'TOKEN_EXPIRED' };
|
|
163
|
+
if (claims.iat > currentTime + clockSkewSeconds) {
|
|
164
|
+
return { ok: false, reason: 'TOKEN_NOT_YET_VALID' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Exact expected context must match the signed claims before any replay
|
|
168
|
+
// consumption, so a mismatched request can never burn a valid nonce.
|
|
169
|
+
if (
|
|
170
|
+
claims.namespace !== request.namespace ||
|
|
171
|
+
claims.op !== request.op ||
|
|
172
|
+
claims.shareId !== request.shareId ||
|
|
173
|
+
claims.contentVersion !== request.contentVersion ||
|
|
174
|
+
claims.ownerProfileId !== request.ownerProfileId ||
|
|
175
|
+
claims.objectId !== request.objectId ||
|
|
176
|
+
claims.operationId !== request.operationId
|
|
177
|
+
) {
|
|
178
|
+
return { ok: false, reason: 'REQUEST_BINDING_MISMATCH' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (claims.requestHash !== request.requestHash) {
|
|
182
|
+
return { ok: false, reason: 'REQUEST_HASH_MISMATCH' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Replay protection covers the whole acceptance window (expiry + skew).
|
|
186
|
+
// `iat` may itself be up to `skew` in the future, so the maximum
|
|
187
|
+
// derivable lifetime is maxTtl + 2 * skew.
|
|
188
|
+
const replayTtlSeconds = claims.exp + clockSkewSeconds - currentTime;
|
|
189
|
+
const maxReplayTtlSeconds = config.maxTokenTtlSeconds + 2 * clockSkewSeconds;
|
|
190
|
+
|
|
191
|
+
if (replayTtlSeconds <= 0 || replayTtlSeconds > maxReplayTtlSeconds) {
|
|
192
|
+
return { ok: false, reason: 'INVALID_TTL' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const replayKey = `share-content-auth:jti:${createHash('sha256')
|
|
196
|
+
.update(`${signerDid}\u0000${claims.jti}`, 'utf8')
|
|
197
|
+
.digest('hex')}`;
|
|
198
|
+
|
|
199
|
+
let consumed: boolean;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
consumed = await replayStore.consumeOnce(replayKey, replayTtlSeconds);
|
|
203
|
+
} catch {
|
|
204
|
+
// Fail closed: an unavailable replay store must never widen access.
|
|
205
|
+
return { ok: false, reason: 'REPLAY_STORE_UNAVAILABLE' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!consumed) return { ok: false, reason: 'REPLAY_DETECTED' };
|
|
209
|
+
|
|
210
|
+
const context: ShareContentAuthorizationContext = {
|
|
211
|
+
signerDid,
|
|
212
|
+
verificationMethod,
|
|
213
|
+
claims,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return { ok: true, context };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return { config, authorize };
|
|
220
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
JWEValidator,
|
|
4
|
+
MAX_SHARE_CIPHERTEXT_BYTES,
|
|
5
|
+
MAX_SHARE_RECOVERY_JWE_BYTES,
|
|
6
|
+
ShareEnvelopeValidator,
|
|
7
|
+
ShareLinkIdValidator,
|
|
8
|
+
ShareOwnerRecoveryValidator,
|
|
9
|
+
decodedBase64UrlByteLength,
|
|
10
|
+
type ShareEnvelope,
|
|
11
|
+
type ShareOwnerRecovery,
|
|
12
|
+
utf8ByteLength,
|
|
13
|
+
} from '@learncard/types';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
computeShareContentRequestHash,
|
|
17
|
+
isOpaqueIdentifier,
|
|
18
|
+
isSafeContentVersion,
|
|
19
|
+
} from '@helpers/share-content-auth/canonical';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* LC-2187 dedicated LearnCloud `share_content` collection.
|
|
23
|
+
*
|
|
24
|
+
* This module defines the *storage* grammar for an immutable encrypted share
|
|
25
|
+
* object plus its permanent deletion tombstone. It is deliberately separate
|
|
26
|
+
* from the recipient manifest owners in `@learncard/types`: it never models
|
|
27
|
+
* plaintext, source URIs or keys, only opaque bindings and the already-encrypted
|
|
28
|
+
* envelope/recovery bytes.
|
|
29
|
+
*
|
|
30
|
+
* Security invariants encoded here:
|
|
31
|
+
* - The immutable identity is `(namespace, objectId)`. Every other binding
|
|
32
|
+
* (`ownerProfileId`, `shareId`, `contentVersion`, `operationId`) is part of
|
|
33
|
+
* the exact tuple and must match on every read/mutation.
|
|
34
|
+
* - `contentHash` is *derived* by {@link computeShareContentHash} over the full
|
|
35
|
+
* validated immutable record (all bindings plus the ciphertext envelope and
|
|
36
|
+
* the owner-encrypted recovery). A caller-supplied hash is never accepted, so
|
|
37
|
+
* a forged hash cannot mask divergent bytes.
|
|
38
|
+
* - The recovery projection is deliberately owner-only at the route layer. The
|
|
39
|
+
* stored recovery JWE is strict at the top level: unknown metadata/URL fields
|
|
40
|
+
* are rejected, not persisted.
|
|
41
|
+
* - Bound sizes: decoded ciphertext (tag included) is at most
|
|
42
|
+
* {@link MAX_SHARE_CIPHERTEXT_BYTES}; the serialized recovery JWE is at most
|
|
43
|
+
* {@link MAX_SHARE_RECOVERY_JWE_BYTES}.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** MongoDB collection name for immutable encrypted share content. */
|
|
47
|
+
export const SHARE_CONTENT_COLLECTION = 'share_content';
|
|
48
|
+
|
|
49
|
+
const SHARE_CONTENT_HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
50
|
+
|
|
51
|
+
/** Opaque, URL-safe binding identifier. Deliberately not a UUID-only check. */
|
|
52
|
+
const opaqueIdentifier = (label: string, maxLength = 128) =>
|
|
53
|
+
z.string().refine(value => isOpaqueIdentifier(value, maxLength), {
|
|
54
|
+
message: `${label} must be an opaque URL-safe identifier of 1..${maxLength} characters`,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Service/network/tenant namespace. Opaque; not a URL or host. */
|
|
58
|
+
export const ShareContentNamespaceValidator = opaqueIdentifier('namespace');
|
|
59
|
+
export type ShareContentNamespace = z.infer<typeof ShareContentNamespaceValidator>;
|
|
60
|
+
|
|
61
|
+
/** Opaque owner profile id (existing ids are opaque strings, not necessarily UUIDs). */
|
|
62
|
+
export const ShareContentOwnerProfileIdValidator = opaqueIdentifier('ownerProfileId');
|
|
63
|
+
export type ShareContentOwnerProfileId = z.infer<typeof ShareContentOwnerProfileIdValidator>;
|
|
64
|
+
|
|
65
|
+
/** Opaque immutable object id reserved by Brain. Never a URI. */
|
|
66
|
+
export const ShareContentObjectIdValidator = opaqueIdentifier('objectId');
|
|
67
|
+
export type ShareContentObjectId = z.infer<typeof ShareContentObjectIdValidator>;
|
|
68
|
+
|
|
69
|
+
/** Opaque operation/idempotency id that owns the immutable object. */
|
|
70
|
+
export const ShareContentOperationIdValidator = opaqueIdentifier('operationId');
|
|
71
|
+
export type ShareContentOperationId = z.infer<typeof ShareContentOperationIdValidator>;
|
|
72
|
+
|
|
73
|
+
/** Positive safe-integer content version (`1..2^31-1`). */
|
|
74
|
+
export const ShareContentVersionValidator = z
|
|
75
|
+
.number()
|
|
76
|
+
.refine(isSafeContentVersion, { message: 'contentVersion must be a safe integer >= 1' });
|
|
77
|
+
export type ShareContentContentVersion = z.infer<typeof ShareContentVersionValidator>;
|
|
78
|
+
|
|
79
|
+
/** Lowercase hex SHA-256 of the derived immutable record. */
|
|
80
|
+
export const ShareContentHashValidator = z
|
|
81
|
+
.string()
|
|
82
|
+
.regex(SHARE_CONTENT_HASH_PATTERN, { message: 'contentHash must be lowercase hex sha256' });
|
|
83
|
+
export type ShareContentHash = z.infer<typeof ShareContentHashValidator>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Strict top-level owner recovery JWE. Wraps the shared
|
|
87
|
+
* {@link ShareOwnerRecoveryValidator} (which enforces the serialized 64 KiB cap)
|
|
88
|
+
* and additionally rejects unknown top-level keys so arbitrary metadata or URLs
|
|
89
|
+
* can never be persisted alongside the opaque recovery.
|
|
90
|
+
*/
|
|
91
|
+
export const ShareContentOwnerRecoveryValidator = JWEValidator.strict().refine(
|
|
92
|
+
value => ShareOwnerRecoveryValidator.safeParse(value).success,
|
|
93
|
+
{ message: 'must be a valid bounded owner-encrypted recovery JWE' }
|
|
94
|
+
);
|
|
95
|
+
export type ShareContentOwnerRecovery = z.infer<typeof ShareContentOwnerRecoveryValidator>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The exact, immutable object binding. The repository applies this to every
|
|
99
|
+
* operation; a mutation of any single field fails closed.
|
|
100
|
+
*/
|
|
101
|
+
export const ShareContentObjectBindingValidator = z
|
|
102
|
+
.object({
|
|
103
|
+
namespace: ShareContentNamespaceValidator,
|
|
104
|
+
ownerProfileId: ShareContentOwnerProfileIdValidator,
|
|
105
|
+
shareId: ShareLinkIdValidator,
|
|
106
|
+
contentVersion: ShareContentVersionValidator,
|
|
107
|
+
objectId: ShareContentObjectIdValidator,
|
|
108
|
+
operationId: ShareContentOperationIdValidator,
|
|
109
|
+
})
|
|
110
|
+
.strict();
|
|
111
|
+
export type ShareContentObjectBinding = z.infer<typeof ShareContentObjectBindingValidator>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A create-only write. `contentHash` is intentionally absent: the repository
|
|
115
|
+
* derives it, so a caller cannot supply (and therefore cannot forge) it.
|
|
116
|
+
*/
|
|
117
|
+
export const ShareContentPutInputValidator = ShareContentObjectBindingValidator.extend({
|
|
118
|
+
envelope: ShareEnvelopeValidator,
|
|
119
|
+
ownerEncryptedRecovery: ShareContentOwnerRecoveryValidator,
|
|
120
|
+
}).strict();
|
|
121
|
+
export type ShareContentPutInput = z.infer<typeof ShareContentPutInputValidator>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Derive the content hash over the complete immutable record: all bindings plus
|
|
125
|
+
* the validated ciphertext envelope and the owner-encrypted recovery. The C1
|
|
126
|
+
* canonical serializer makes the result deterministic and rejects unsupported
|
|
127
|
+
* values instead of coercing them.
|
|
128
|
+
*/
|
|
129
|
+
export const computeShareContentHash = (input: ShareContentPutInput): string =>
|
|
130
|
+
computeShareContentRequestHash({
|
|
131
|
+
namespace: input.namespace,
|
|
132
|
+
ownerProfileId: input.ownerProfileId,
|
|
133
|
+
shareId: input.shareId,
|
|
134
|
+
contentVersion: input.contentVersion,
|
|
135
|
+
objectId: input.objectId,
|
|
136
|
+
operationId: input.operationId,
|
|
137
|
+
envelope: input.envelope,
|
|
138
|
+
ownerEncryptedRecovery: input.ownerEncryptedRecovery,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Hash known before Brain allocates object/operation IDs; reservation binding. */
|
|
142
|
+
export const computeSharePayloadHash = (
|
|
143
|
+
input: Pick<ShareContentPutInput, 'envelope' | 'ownerEncryptedRecovery'>
|
|
144
|
+
): string =>
|
|
145
|
+
computeShareContentRequestHash({
|
|
146
|
+
envelope: input.envelope,
|
|
147
|
+
ownerEncryptedRecovery: input.ownerEncryptedRecovery,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Measure the encoded payload for reconciliation/stats. Assumes `input` already
|
|
152
|
+
* passed {@link ShareContentPutInputValidator}.
|
|
153
|
+
*/
|
|
154
|
+
export const measureShareContentPayload = (
|
|
155
|
+
input: Pick<ShareContentPutInput, 'envelope' | 'ownerEncryptedRecovery'>
|
|
156
|
+
): { ciphertextBytes: number; recoveryBytes: number } => {
|
|
157
|
+
const ciphertextBytes = decodedBase64UrlByteLength(input.envelope.ct);
|
|
158
|
+
|
|
159
|
+
if (ciphertextBytes === null) {
|
|
160
|
+
throw new Error('validated envelope ciphertext must be canonical base64url');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ciphertextBytes,
|
|
165
|
+
recoveryBytes: utf8ByteLength(JSON.stringify(input.ownerEncryptedRecovery)),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Immutable active document. */
|
|
170
|
+
export const MongoShareContentActiveValidator = ShareContentObjectBindingValidator.extend({
|
|
171
|
+
kind: z.literal('active'),
|
|
172
|
+
contentHash: ShareContentHashValidator,
|
|
173
|
+
payloadHash: ShareContentHashValidator,
|
|
174
|
+
envelope: ShareEnvelopeValidator,
|
|
175
|
+
ownerEncryptedRecovery: ShareContentOwnerRecoveryValidator,
|
|
176
|
+
ciphertextBytes: z.number().int().min(16).max(MAX_SHARE_CIPHERTEXT_BYTES),
|
|
177
|
+
recoveryBytes: z.number().int().min(1).max(MAX_SHARE_RECOVERY_JWE_BYTES),
|
|
178
|
+
createdAt: z.date(),
|
|
179
|
+
}).strict();
|
|
180
|
+
export type MongoShareContentActive = z.infer<typeof MongoShareContentActiveValidator>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Permanent deletion tombstone. Retains the binding tuple and, where known, the
|
|
184
|
+
* derived hash; never retains ciphertext or recovery. A tombstone blocks every
|
|
185
|
+
* later put for the same `(namespace, objectId)` identity.
|
|
186
|
+
*/
|
|
187
|
+
export const MongoShareContentTombstoneValidator = ShareContentObjectBindingValidator.extend({
|
|
188
|
+
kind: z.literal('tombstone'),
|
|
189
|
+
contentHash: ShareContentHashValidator.optional(),
|
|
190
|
+
deletedAt: z.date(),
|
|
191
|
+
}).strict();
|
|
192
|
+
export type MongoShareContentTombstone = z.infer<typeof MongoShareContentTombstoneValidator>;
|
|
193
|
+
|
|
194
|
+
/** Stored document union. */
|
|
195
|
+
export type MongoShareContentDocument = MongoShareContentActive | MongoShareContentTombstone;
|
|
196
|
+
|
|
197
|
+
/** Raw ciphertext envelope carried by an active object. */
|
|
198
|
+
export type { ShareEnvelope, ShareOwnerRecovery };
|