@learncard/learn-cloud-service 2.5.34 → 2.5.36
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 +24 -12
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +68 -0
- package/config/config.dev.json +8 -0
- package/config/config.example.json +8 -0
- package/config/config.json +1 -0
- package/config/config.production.json +8 -0
- package/config/config.scouts.dev.json +3 -0
- package/config/config.scouts.production.json +4 -0
- package/didWebLambda.test.ts +41 -0
- package/didWebLambda.ts +13 -19
- package/didWebLambdaApp.ts +23 -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/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/environment.d.ts +12 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +10 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.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/esbuildPlugins.cjs +1 -0
- package/lambda.ts +22 -92
- package/lambdaApp.ts +163 -0
- package/oidcLambda.test.ts +41 -0
- package/oidcLambda.ts +13 -19
- package/oidcLambdaApp.ts +23 -0
- package/package.json +18 -16
- package/serverless-local.yml +3 -1
- package/serverless.function-env.cjs +31 -0
- package/serverless.yml +31 -22
- 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/applyDockerStageConfig.ts +6 -0
- package/src/config/environment.ts +33 -0
- package/src/config/serverlessFunctionEnv.test.ts +81 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +21 -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 +4 -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
- package/xApiLambda.test.ts +41 -0
- package/xApiLambda.ts +13 -19
- package/xApiLambdaApp.ts +23 -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
|
+
};
|
|
@@ -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
|
+
};
|