@3sln/trove 0.0.10 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  // nothing replayable. It is shown to the minter exactly once.
27
27
 
28
28
  import { TroveError } from './errors.js';
29
+ import { timingSafeEqual } from './timingSafe.js';
29
30
  import { CAPABILITIES, expand } from './collections/index.js';
30
31
 
31
32
  const NS = 'api-keys';
@@ -72,19 +73,6 @@ async function sha256(text) {
72
73
  return b64url(new Uint8Array(digest));
73
74
  }
74
75
 
75
- /**
76
- * Compare without leaking where two strings diverge.
77
- *
78
- * The hash of a presented secret against the stored hash. A `===` here would return
79
- * faster the earlier it finds a difference, which over enough attempts is a way to learn
80
- * a prefix — the classic reason credential comparison is not string equality.
81
- */
82
- function timingSafeEqual(a, b) {
83
- if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
84
- let diff = 0;
85
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
86
- return diff === 0;
87
- }
88
76
 
89
77
  /** Normalise and validate one `{ collectionId, capabilities }` entry. */
90
78
  function normalizeScope(scope) {
@@ -324,6 +324,30 @@ export class CollectionService {
324
324
  return all.every((c) => this.can(principal, c, 'read') && this.can(principal, c, 'write'));
325
325
  }
326
326
 
327
+ /**
328
+ * What this principal may reach across the whole drive, and whether that is anything.
329
+ *
330
+ * The ACL as a DECISION rather than as a record, so something outside this library can
331
+ * ask it — an edge policy deciding whether an email gets through the front door at all,
332
+ * for instance. Everything it needs is already here; what was missing was a way to ask
333
+ * without reimplementing `can()` against the grant shape, which is exactly how two
334
+ * copies of an authorization rule start.
335
+ *
336
+ * "Allowed" means: a named admin, or read on at least one collection. Read on nothing is
337
+ * the honest definition of someone with no business here — note that on a `defaultOpen`
338
+ * drive the `anyone` grant makes that true for everybody, which is correct, because the
339
+ * ACL is what says so.
340
+ */
341
+ async accessFor(principal) {
342
+ const admin = this.isAdmin(principal);
343
+ const collections = await this.list(principal);
344
+ return {
345
+ allowed: admin || collections.length > 0,
346
+ admin,
347
+ collections: collections.map((c) => ({ id: c.id, name: c.name, capabilities: c.capabilities })),
348
+ };
349
+ }
350
+
327
351
  /** Global admin (can do anything, incl. grant admin-only plugin capabilities). */
328
352
  isAdmin(principal) {
329
353
  return this.#isNamedAdmin(principal);
@@ -141,6 +141,54 @@ async function importVerifyKey(alg, key) {
141
141
  * @param {number} [opts.clockToleranceSec]
142
142
  * @param {number|null} [opts.now] ms epoch; pass null to say there is no clock
143
143
  */
144
+ /** base64url WITHOUT padding, which is what a JWT wants everywhere. */
145
+ function bytesToBase64url(bytes) {
146
+ let bin = '';
147
+ for (const b of bytes) bin += String.fromCharCode(b);
148
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
149
+ }
150
+
151
+ /**
152
+ * Sign a JWT with a private JWK.
153
+ *
154
+ * The mirror of `verifyJwt`, and it exists for one caller: answering an external policy
155
+ * evaluation, where the asker fetches OUR public key to check the answer. That is why it is
156
+ * asymmetric where `signedUrls.js` is happy with HMAC — a shared secret would mean handing
157
+ * the verifier the ability to mint answers.
158
+ *
159
+ * RS256 by default, and that is not a preference. Cloudflare Access verifies these, its
160
+ * documentation says an RSA key pair and that "other key formats are not supported", and
161
+ * its own reference implementation signs with RSASSA-PKCS1-v1_5 over SHA-256. ES256 is a
162
+ * better curve and would have been silently rejected by the only thing that reads these.
163
+ */
164
+ export async function signJwt(payload, { privateJwk, kid, alg = 'RS256', expiresInSec = 60, now = Date.now() } = {}) {
165
+ if (!privateJwk) throw TroveError.invalid('Signing a JWT needs a private JWK');
166
+ const spec = ALGS[alg];
167
+ if (!spec || alg === 'HS256') throw TroveError.unsupported(`Cannot sign a JWT with alg ${alg}`);
168
+ const iat = Math.floor(now / 1000);
169
+ const body = { iat, exp: iat + expiresInSec, ...payload };
170
+ const header = { alg, typ: 'JWT', ...(kid ? { kid } : {}) };
171
+ const signingInput = `${bytesToBase64url(enc.encode(JSON.stringify(header)))}.`
172
+ + `${bytesToBase64url(enc.encode(JSON.stringify(body)))}`;
173
+ const key = await crypto.subtle.importKey('jwk', { ...privateJwk, alg: undefined, key_ops: undefined }, spec.import, false, ['sign']);
174
+ const sig = new Uint8Array(await crypto.subtle.sign(spec.verify, key, enc.encode(signingInput)));
175
+ return `${signingInput}.${bytesToBase64url(sig)}`;
176
+ }
177
+
178
+ /**
179
+ * The public half of a private JWK, as a JWKS entry.
180
+ *
181
+ * Strips `d` — the private scalar — and everything else a signer needs and a verifier must
182
+ * not have. Written as a subtraction rather than a copy of the public fields so a JWK that
183
+ * grows a field cannot silently start publishing it.
184
+ */
185
+ export function publicJwkOf(privateJwk, { kid, alg = 'RS256' } = {}) {
186
+ const { d, p, q, dp, dq, qi, ...pub } = privateJwk || {};
187
+ // `alg` is published because Cloudflare's own keys endpoint publishes it, and a verifier
188
+ // that trusts the JWK's alg over the token header's is the safer of the two.
189
+ return { ...pub, alg, key_ops: ['verify'], use: 'sig', ...(kid ? { kid } : {}) };
190
+ }
191
+
144
192
  export async function verifyJwt(token, opts = {}) {
145
193
  const { header, payload, parts } = decodeJwt(token);
146
194
  const alg = header.alg;
@@ -83,7 +83,8 @@ export {
83
83
  AnonymousIdentityProvider, principalFromClaims,
84
84
  cloudflareAccess, accessHost,
85
85
  } from './identity/index.js';
86
- export { verifyJwt, decodeJwt, JwksClient, StaticJwks } from './identity/jwt.js';
86
+ export { verifyJwt, signJwt, publicJwkOf, decodeJwt, JwksClient, StaticJwks } from './identity/jwt.js';
87
+ export { timingSafeEqual } from './timingSafe.js';
87
88
  // Where an unauthenticated client is told to go — one answer for the whole drive.
88
89
  export {
89
90
  protectedResourceMetadata, challengeHeaders, metadataUrl, publicOrigin,
@@ -18,6 +18,7 @@
18
18
  // may see must not be editable into one for a file you may not.
19
19
 
20
20
  import { TroveError } from './errors.js';
21
+ import { timingSafeEqual } from './timingSafe.js';
21
22
 
22
23
  const MINUTES = 60;
23
24
  const HOURS = 60 * MINUTES;
@@ -132,19 +133,6 @@ export class SignedUrls {
132
133
  }
133
134
  }
134
135
 
135
- /**
136
- * Constant-time string compare.
137
- *
138
- * `a === b` on a signature leaks where the first differing byte is, which over enough
139
- * requests is a forgery oracle. The cost of not caring is small and the cost of caring is
140
- * nothing.
141
- */
142
- function timingSafeEqual(a, b) {
143
- if (a.length !== b.length) return false;
144
- let diff = 0;
145
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
146
- return diff === 0;
147
- }
148
136
 
149
137
  /**
150
138
  * A secret that survives a restart and is shared between instances.
@@ -0,0 +1,30 @@
1
+ // Comparing a credential without saying how nearly you got it right.
2
+ //
3
+ // `a === b` returns as soon as it finds a difference, so it is fast for a wrong first byte
4
+ // and slower for a wrong last one. Over enough attempts that difference is measurable, and
5
+ // measuring it recovers the secret one byte at a time — which is why credential comparison
6
+ // is never string equality.
7
+ //
8
+ // One copy, and this file exists because there were two: `signedUrls.js` and `apiKeys.js`
9
+ // each grew their own, and the two had already drifted — one guarded its argument types and
10
+ // the other trusted the call site. That is the cheapest possible version of the bug where
11
+ // two copies of a security rule disagree, and it is worth not having.
12
+
13
+ /**
14
+ * Do these two strings match, in time that does not depend on where they differ?
15
+ *
16
+ * Length is NOT hidden: returning early on a length mismatch leaks how long the secret is,
17
+ * which for a fixed-width hash or signature is public anyway. Hiding it would mean hashing
18
+ * both sides first, and every caller here compares values that are already digests.
19
+ *
20
+ * A non-string is false rather than a throw. The inputs come off the wire, `undefined` is a
21
+ * perfectly ordinary thing for a missing header to be, and a comparison that throws on it
22
+ * turns a failed auth attempt into a 500.
23
+ */
24
+ export function timingSafeEqual(a, b) {
25
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
26
+ if (a.length !== b.length) return false;
27
+ let diff = 0;
28
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
29
+ return diff === 0;
30
+ }
@@ -0,0 +1,169 @@
1
+ // External policy evaluation — the drive's ACL, answering an identity provider.
2
+ //
3
+ // WHAT THIS IS FOR
4
+ //
5
+ // Cloudflare Access can call out to a service mid-login and ask "should this person be let
6
+ // in?". Point it here and the answer comes from the collection ACLs: whoever has read on
7
+ // at least one collection gets through the front door, and nobody else reaches the drive at
8
+ // all. Access stays the single place you edit — the administration screen — and it now
9
+ // governs two things instead of one:
10
+ //
11
+ // the EDGE who gets past Cloudflare, decided here
12
+ // the BUCKET what they may do once inside, decided by the same ACL, as it always was
13
+ //
14
+ // The second half is unchanged and still authoritative. This does not replace an internal
15
+ // check anywhere; letting someone through the door does not grant them a collection. If
16
+ // this component is switched off, nothing about the drive's own guarding changes.
17
+ //
18
+ // OPTIONAL, and off unless configured. Mounted exactly like a notification channel — the
19
+ // component contributes routes and `createServer` adds them — so a drive that has not
20
+ // configured it has no `/api/access/*` at all rather than endpoints that answer "no".
21
+ //
22
+ // SETTING IT UP
23
+ //
24
+ // 1. Make a key pair. It is asymmetric because Cloudflare must be able to CHECK our
25
+ // answer without being able to MINT one — and it is RSA because Access says so:
26
+ // "other key formats are not supported", and its reference implementation signs
27
+ // RS256. An EC key here is silently rejected by the only thing that reads these.
28
+ //
29
+ // node -e "crypto.subtle.generateKey({name:'RSASSA-PKCS1-v1_5',modulusLength:2048,
30
+ // publicExponent:new Uint8Array([1,0,1]),hash:'SHA-256'},true,['sign','verify'])
31
+ // .then(k=>crypto.subtle.exportKey('jwk',k.privateKey)).then(j=>console.log(JSON.stringify(j)))"
32
+ //
33
+ // 2. TROVE_ACCESS_EVAL_KEY=<that JSON> (and TROVE_CF_ACCESS_TEAM if not already set)
34
+ // 3. In the Access policy, add an External Evaluation rule:
35
+ // Evaluate URL https://<your drive>/api/access/evaluate
36
+ // Keys URL https://<your drive>/api/access/keys
37
+ //
38
+ // 4. AND let Access reach them. If the drive sits behind Access — which is the whole
39
+ // point — then by default these two paths do as well, and Access would have to
40
+ // authenticate to itself to call them. It cannot, so it fails with nothing useful to
41
+ // say. Add a Bypass policy for `/api/access/*`.
42
+ //
43
+ // Bypassing is safe here rather than a concession, and that is by construction: the
44
+ // keys endpoint serves a public key, and the evaluate endpoint authenticates its
45
+ // caller itself by verifying the assertion. Neither ever depended on Access for its
46
+ // own protection. They are also `public: true`, so this drive's OWN identity
47
+ // requirement does not apply to them either — Access holds no Trove session and
48
+ // never will.
49
+ //
50
+ // WHY IT REFUSES TO RUN UNVERIFIED
51
+ //
52
+ // This endpoint answers "does this email have access to this drive". That is a question
53
+ // worth lying to strangers about, so it will not answer one it cannot attribute: the
54
+ // caller's assertion must verify against the configured team's JWKS. Without a team
55
+ // configured the component declines to mount rather than mounting an open oracle that
56
+ // enumerates your users to anyone who can reach it.
57
+ //
58
+ // THE TRANSPORT, CHECKED AGAINST CLOUDFLARE (Aug 2026)
59
+ //
60
+ // Verified against the External Evaluation docs and their reference Worker:
61
+ //
62
+ // in POST, JSON body `{ token }`, the JWT signed by the Access account key
63
+ // out JSON body `{ token }`, a JWT of `{ success, iat, exp, nonce }`, RS256
64
+ // keys `{ keys: [ <public JWK, with kid> ] }`
65
+ //
66
+ // The identity lands at `claims.identity.email` in their example rather than at the top
67
+ // level, which is why `principalOf` looks in both places. `parseAssertion` stays generous
68
+ // about where it finds the incoming JWT: the token is self-authenticating, so accepting it
69
+ // from more than one place costs nothing and survives a small change on their side.
70
+
71
+ import { TroveError, verifyJwt, signJwt, publicJwkOf, JwksClient } from '@3sln/trove/core';
72
+
73
+ /** Where Cloudflare publishes the keys for a team's own assertions. */
74
+ const teamJwksUrl = (team) => `https://${team}.cloudflareaccess.com/cdn-cgi/access/certs`;
75
+
76
+ /**
77
+ * Find the assertion in whatever shape it arrived.
78
+ *
79
+ * Generous on purpose — see the header. The JWT is the only thing that matters and it is
80
+ * self-authenticating, so accepting it from several places costs nothing: a forgery is
81
+ * still a forgery wherever it was found.
82
+ */
83
+ export async function parseAssertion(req) {
84
+ const header = req.headers?.get?.('cf-access-jwt-assertion');
85
+ if (header) return header;
86
+ const text = await req.text();
87
+ if (!text) return null;
88
+ try {
89
+ const body = JSON.parse(text);
90
+ return body.token || body.jwt || body.assertion || null;
91
+ } catch {
92
+ // Not JSON: some callers post the bare token.
93
+ return text.trim() || null;
94
+ }
95
+ }
96
+
97
+ /** The identity Cloudflare is asking about, as a principal this drive understands. */
98
+ export function principalOf(claims) {
99
+ const email = claims?.email || claims?.identity?.email || claims?.sub || null;
100
+ if (!email) return null;
101
+ return { id: email, email, name: claims?.name || email, roles: claims?.groups || claims?.roles || [] };
102
+ }
103
+
104
+ /**
105
+ * An external-evaluation component, or null when this drive has not configured one.
106
+ *
107
+ * @param {object} cfg
108
+ * @param {object} cfg.privateJwk EC P-256 private key, the one whose public half we publish
109
+ * @param {string} cfg.team Cloudflare Access team, for verifying the caller
110
+ * @param {string} [cfg.kid] key id, so the key can be rotated without an outage
111
+ */
112
+ export function externalEvaluation({ privateJwk, team, kid = 'trove-access', jwks, now = Date.now } = {}) {
113
+ if (!privateJwk) return null;
114
+ // Refusing rather than warning: see the header. An oracle nobody authenticated is worse
115
+ // than no oracle.
116
+ if (!team && !jwks) {
117
+ throw TroveError.invalid(
118
+ 'External evaluation needs a Cloudflare Access team to verify callers against — set TROVE_CF_ACCESS_TEAM',
119
+ );
120
+ }
121
+ const keys = jwks || new JwksClient(teamJwksUrl(team));
122
+
123
+ return {
124
+ name: 'cloudflare-external-evaluation',
125
+ routes() {
126
+ return [
127
+ {
128
+ method: 'POST',
129
+ path: '/api/access/evaluate',
130
+ deps: ['collections'],
131
+ // No Trove identity: the caller is Cloudflare, not a user of this drive, and it
132
+ // authenticates by signing the assertion — which `verifyJwt` below checks. A
133
+ // session requirement here would be asking Access to log in as somebody.
134
+ public: true,
135
+ async handler(ctx) {
136
+ const token = await parseAssertion(ctx.req);
137
+ if (!token) throw TroveError.invalid('No access assertion in the request');
138
+ // Verified before it is read. Everything below trusts these claims, so this
139
+ // line is the whole security of the component.
140
+ const claims = await verifyJwt(token, { jwks: keys, now: now() });
141
+ const principal = principalOf(claims);
142
+
143
+ // No identity is a "no", not an error: Access is asking about somebody and we
144
+ // cannot say yes about somebody we cannot name.
145
+ const decision = principal && ctx.collections
146
+ ? await ctx.collections.accessFor(principal)
147
+ : { allowed: false, admin: false, collections: [] };
148
+
149
+ const answer = await signJwt(
150
+ { success: !!decision.allowed, nonce: claims.nonce, email: principal?.email || null },
151
+ { privateJwk, kid, now: now() },
152
+ );
153
+ return { token: answer };
154
+ },
155
+ },
156
+ {
157
+ method: 'GET',
158
+ path: '/api/access/keys',
159
+ deps: [],
160
+ public: true,
161
+ // Public by design: it is a public key, and Cloudflare fetches it unauthenticated.
162
+ handler() {
163
+ return { keys: [publicJwkOf(privateJwk, { kid })] };
164
+ },
165
+ },
166
+ ];
167
+ },
168
+ };
169
+ }
@@ -23,6 +23,7 @@ import { storageRegistry } from './engine/providers/core.js';
23
23
  import { createMcpHandler } from './mcp/index.js';
24
24
  import { cacheControlFor } from './cachePolicy.js';
25
25
  import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
26
+ import { externalEvaluation } from './access/externalEvaluation.js';
26
27
 
27
28
  // Every backend is pluggable. Each field of `config` accepts EITHER a ready
28
29
  // provider instance (pass your own class) OR a `{ driver, ... }` config object
@@ -325,6 +326,24 @@ export async function createServer(config = {}) {
325
326
  }
326
327
  }
327
328
 
329
+ // The external policy component, on the same terms: it contributes routes or it does not
330
+ // exist. A drive that has not configured one has no `/api/access/*` at all, rather than
331
+ // endpoints that exist to answer "no" — which is the difference between a feature that is
332
+ // off and a feature that is broken.
333
+ // Paths a contributed component declared as needing no Trove identity. Exact matches
334
+ // only, and that is deliberate: the check below runs BEFORE routing, so it cannot know
335
+ // which parameterised route would have matched. A component that wants a public route
336
+ // gives it a fixed path.
337
+ const publicPaths = new Set();
338
+
339
+ const accessPolicy = config.accessEvaluation
340
+ ? externalEvaluation({ ...config.accessEvaluation, team: config.accessEvaluation.team || config.identity?.access?.team })
341
+ : null;
342
+ for (const route of accessPolicy?.routes?.(routeHelpers) || []) {
343
+ router.add(route.method, route.path, route.deps || [], route.handler);
344
+ if (route.public) publicPaths.add(route.path);
345
+ }
346
+
328
347
  // Said at boot, because that is when someone is looking and can still fix it. The
329
348
  // alternative is discovering it from a client that can't sign in and a 401 that
330
349
  // doesn't say why.
@@ -397,8 +416,14 @@ export async function createServer(config = {}) {
397
416
  let principal = null;
398
417
  let grant = null;
399
418
  try {
400
- grant = await capabilities.resolve(req);
401
- if (!grant) principal = await identity.authenticate(req);
419
+ // A public route answers to something other than this drive's identity — the
420
+ // external policy endpoints verify a Cloudflare-signed assertion themselves, and
421
+ // the keys endpoint serves a public key. Requiring a session on those is asking
422
+ // the caller to authenticate as a user it is not and does not have.
423
+ if (!publicPaths.has(url.pathname)) {
424
+ grant = await capabilities.resolve(req);
425
+ if (!grant) principal = await identity.authenticate(req);
426
+ }
402
427
  } catch (err) {
403
428
  const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
404
429
  return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
@@ -850,6 +875,22 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
850
875
  }
851
876
 
852
877
  // Cross-origin API access is off unless an origin (or '*') is configured.
878
+ // External policy evaluation: present only when a signing key is. See
879
+ // access/externalEvaluation.js for how to make one.
880
+ if (env.TROVE_ACCESS_EVAL_KEY) {
881
+ let privateJwk;
882
+ try {
883
+ privateJwk = JSON.parse(env.TROVE_ACCESS_EVAL_KEY);
884
+ } catch {
885
+ throw TroveError.invalid('TROVE_ACCESS_EVAL_KEY must be a private JWK as JSON');
886
+ }
887
+ config.accessEvaluation = {
888
+ privateJwk,
889
+ team: env.TROVE_CF_ACCESS_TEAM || null,
890
+ kid: env.TROVE_ACCESS_EVAL_KID || 'trove-access',
891
+ };
892
+ }
893
+
853
894
  config.corsOrigin = env.TROVE_CORS_ORIGIN || null;
854
895
  // App-shell CSP is opt-in (see SAMPLE_CSP) — provide a full policy string to
855
896
  // enable it. Off by default because sandboxed plugin iframes can't satisfy one.
@@ -272,6 +272,27 @@ export function createRouter() {
272
272
  return collections.remove(params.id, principal);
273
273
  });
274
274
 
275
+ /**
276
+ * Who has access to this collection.
277
+ *
278
+ * A separate route rather than a field on `describe()`, which is documented as "a safe,
279
+ * principal-scoped view (no secrets)" and is returned to everyone who can see the
280
+ * collection at all. The ACL is not a secret exactly, but it names people, and every
281
+ * listing would carry it. Asking for it is an explicitly admin-gated act.
282
+ */
283
+ r.get('/api/collections/:id/grants', ['collections'], async (ctx) => {
284
+ requireCollections(ctx);
285
+ const c = await ctx.collections.assert(ctx.principal, ctx.params.id, 'admin');
286
+ // Drive administrators come from the DEPLOYMENT (TROVE_ADMINS), not from this ACL, and
287
+ // returning them alongside rather than inside `grants` is the honest shape: they hold
288
+ // admin on every collection, `setGrant` cannot touch them, and a UI that listed them as
289
+ // grants would offer a revoke button that silently does nothing.
290
+ return {
291
+ grants: c.acl?.grants || [],
292
+ admins: [...(ctx.collections.admins || [])],
293
+ };
294
+ });
295
+
275
296
  r.post('/api/collections/:id/grants', ['collections'], async (ctx) => {
276
297
  requireCollections(ctx);
277
298
  return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };