@pdsjs/spaces 2.0.1 → 2.0.2

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": "@pdsjs/spaces",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  "./service-auth": "./src/service-auth.js"
24
24
  },
25
25
  "dependencies": {
26
- "@pdsjs/core": "2.1.0"
26
+ "@pdsjs/core": "2.2.0"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"
@@ -32,6 +32,16 @@ export declare function atprotoSigningKey(didDoc: any, did: string): string;
32
32
  * @returns {string}
33
33
  */
34
34
  export declare function spaceHostEndpoint(didDoc: any, did: string): string;
35
+ /**
36
+ * Where a service identifier points. A service identifier is a DID with an
37
+ * optional fragment naming the entry in its DID document. Without a fragment
38
+ * the account's ordinary PDS entry answers.
39
+ *
40
+ * @param {any} didDoc
41
+ * @param {string} service - e.g. 'did:web:syncer.example#atproto_space_syncer'
42
+ * @returns {string|null}
43
+ */
44
+ export declare function namedServiceEndpoint(didDoc: any, service: string): string | null;
35
45
  /**
36
46
  * The `aud` a delegation token must carry for a given authority.
37
47
  * @param {string} spaceDid
package/src/authority.js CHANGED
@@ -120,6 +120,24 @@ export function spaceHostEndpoint(didDoc, did) {
120
120
  return endpoint;
121
121
  }
122
122
 
123
+ /**
124
+ * Where a service identifier points. A service identifier is a DID with an
125
+ * optional fragment naming the entry in its DID document. Without a fragment
126
+ * the account's ordinary PDS entry answers.
127
+ *
128
+ * @param {any} didDoc
129
+ * @param {string} service - e.g. 'did:web:syncer.example#atproto_space_syncer'
130
+ * @returns {string|null}
131
+ */
132
+ export function namedServiceEndpoint(didDoc, service) {
133
+ const [did, fragment] = service.split('#');
134
+ return serviceEndpoint(
135
+ didDoc,
136
+ did,
137
+ fragment ? `#${fragment}` : ATPROTO_PDS_ID,
138
+ );
139
+ }
140
+
123
141
  /**
124
142
  * The `aud` a delegation token must carry for a given authority.
125
143
  * @param {string} spaceDid
package/src/car.d.ts CHANGED
@@ -15,16 +15,18 @@ export type SerializedRecord = {
15
15
  rkey: string;
16
16
  cid: string;
17
17
  /**
18
- * - DAG-CBOR encoded record
18
+ * - DAG-CBOR encoded record, absent under
19
+ * excludeValues
19
20
  */
20
- value: Uint8Array;
21
+ value?: Uint8Array;
21
22
  };
22
23
  /**
23
24
  * @typedef {Object} SerializedRecord
24
25
  * @property {string} collection
25
26
  * @property {string} rkey
26
27
  * @property {string} cid
27
- * @property {Uint8Array} value - DAG-CBOR encoded record
28
+ * @property {Uint8Array} [value] - DAG-CBOR encoded record, absent under
29
+ * excludeValues
28
30
  */
29
31
  /**
30
32
  * Serialize a repo as a CAR: two roots in order — the signed commit, then the
@@ -34,8 +36,15 @@ export type SerializedRecord = {
34
36
  * Records are collected up front because the index has to precede the blocks it
35
37
  * describes.
36
38
  *
39
+ * Under `excludeValues` the CAR carries the two roots and no other block. The
40
+ * index still folds into the same set hash, so a syncer authenticates it against
41
+ * the commit and then fetches only the records it lacks.
42
+ *
37
43
  * @param {import('./commit.js').SignedCommit} commit
38
44
  * @param {Iterable<SerializedRecord>} records
45
+ * @param {{excludeValues?: boolean}} [opts]
39
46
  * @returns {Promise<Uint8Array>}
40
47
  */
41
- export declare function serializeRepo(commit: import('./commit.js').SignedCommit, records: Iterable<SerializedRecord>): Promise<Uint8Array>;
48
+ export declare function serializeRepo(commit: import('./commit.js').SignedCommit, records: Iterable<SerializedRecord>, opts?: {
49
+ excludeValues?: boolean;
50
+ }): Promise<Uint8Array>;
package/src/car.js CHANGED
@@ -31,7 +31,8 @@ export function byCanonicalKey(a, b) {
31
31
  * @property {string} collection
32
32
  * @property {string} rkey
33
33
  * @property {string} cid
34
- * @property {Uint8Array} value - DAG-CBOR encoded record
34
+ * @property {Uint8Array} [value] - DAG-CBOR encoded record, absent under
35
+ * excludeValues
35
36
  */
36
37
 
37
38
  /**
@@ -42,11 +43,16 @@ export function byCanonicalKey(a, b) {
42
43
  * Records are collected up front because the index has to precede the blocks it
43
44
  * describes.
44
45
  *
46
+ * Under `excludeValues` the CAR carries the two roots and no other block. The
47
+ * index still folds into the same set hash, so a syncer authenticates it against
48
+ * the commit and then fetches only the records it lacks.
49
+ *
45
50
  * @param {import('./commit.js').SignedCommit} commit
46
51
  * @param {Iterable<SerializedRecord>} records
52
+ * @param {{excludeValues?: boolean}} [opts]
47
53
  * @returns {Promise<Uint8Array>}
48
54
  */
49
- export async function serializeRepo(commit, records) {
55
+ export async function serializeRepo(commit, records, opts = {}) {
50
56
  /** @type {Map<string, SerializedRecord>} */
51
57
  const byPath = new Map();
52
58
  for (const record of records) {
@@ -71,10 +77,15 @@ export async function serializeRepo(commit, records) {
71
77
  const blocks = [
72
78
  { cid: commitRoot, data: commitBytes },
73
79
  { cid: indexRoot, data: indexBytes },
74
- ...paths.map((path) => {
75
- const record = /** @type {SerializedRecord} */ (byPath.get(path));
76
- return { cid: record.cid, data: record.value };
77
- }),
80
+ ...(opts.excludeValues
81
+ ? []
82
+ : paths.map((path) => {
83
+ const record = /** @type {SerializedRecord} */ (byPath.get(path));
84
+ return {
85
+ cid: record.cid,
86
+ data: /** @type {Uint8Array} */ (record.value),
87
+ };
88
+ })),
78
89
  ];
79
90
 
80
91
  return buildCarFile([commitRoot, indexRoot], blocks);
package/src/dpop.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Check the DPoP proof presented with a space credential.
3
+ *
4
+ * @param {Object} opts
5
+ * @param {Request} opts.request - the request the proof must cover
6
+ * @param {string} opts.credential - the raw credential, hashed into `ath`
7
+ * @param {string} opts.jkt - the thumbprint from the credential's `cnf.jkt`
8
+ * @returns {Promise<void>}
9
+ * @throws {SpaceTokenError} when the proof is absent, invalid, or replayed
10
+ */
11
+ export declare function verifyCredentialProof({ request, credential, jkt }: {
12
+ request: Request;
13
+ credential: string;
14
+ jkt: string;
15
+ }): Promise<void>;
package/src/dpop.js ADDED
@@ -0,0 +1,85 @@
1
+ // @pdsjs/spaces/dpop - the key a space credential is bound to (RFC 9449).
2
+ //
3
+ // One credential reads a whole space, and the holder presents it to every repo
4
+ // host in that space. A bearer credential would therefore be a shared secret: a
5
+ // host handed one to serve its own repo could replay it against every other host
6
+ // in the space. So the authority binds the credential to a key the application
7
+ // names as a thumbprint, and each request carries a fresh proof signed by that
8
+ // key.
9
+ //
10
+ // The proof itself is verified by @pdsjs/core/oauth, which does the same work
11
+ // for OAuth access tokens. What this module adds is the space rules: the proof
12
+ // is mandatory, and a jti is accepted once.
13
+
14
+ import { parseDpopProof } from '@pdsjs/core/oauth';
15
+ import { SpaceTokenError } from './token.js';
16
+
17
+ // parseDpopProof accepts an `iat` up to 300 seconds old, so a jti has to be
18
+ // remembered for longer than that. Otherwise a proof outlives the memory of it
19
+ // and the last few seconds of its life are replayable.
20
+ const JTI_RETENTION_MS = 360_000;
21
+
22
+ /** @type {Map<string, number>} jti -> the instant it may be forgotten */
23
+ const seenJtis = new Map();
24
+
25
+ /**
26
+ * Record a jti, answering whether this is the first time it was presented.
27
+ *
28
+ * Held in memory, which is sound for the same reason the write queue in
29
+ * writer.js is: one space is served by exactly one process, a Node PDS or a
30
+ * single Durable Object. Expired entries are dropped on each call, so the map
31
+ * holds one request's worth of traffic rather than growing without bound.
32
+ *
33
+ * @param {string} jti
34
+ * @param {number} now
35
+ * @returns {boolean}
36
+ */
37
+ function firstUse(jti, now) {
38
+ for (const [seen, expiresAt] of seenJtis) {
39
+ if (expiresAt <= now) seenJtis.delete(seen);
40
+ }
41
+ if (seenJtis.has(jti)) return false;
42
+ seenJtis.set(jti, now + JTI_RETENTION_MS);
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Check the DPoP proof presented with a space credential.
48
+ *
49
+ * @param {Object} opts
50
+ * @param {Request} opts.request - the request the proof must cover
51
+ * @param {string} opts.credential - the raw credential, hashed into `ath`
52
+ * @param {string} opts.jkt - the thumbprint from the credential's `cnf.jkt`
53
+ * @returns {Promise<void>}
54
+ * @throws {SpaceTokenError} when the proof is absent, invalid, or replayed
55
+ */
56
+ export async function verifyCredentialProof({ request, credential, jkt }) {
57
+ const proof = request.headers.get('dpop');
58
+ if (!proof) {
59
+ throw new SpaceTokenError(
60
+ 'A space credential requires a DPoP proof',
61
+ 'MissingDpopProof',
62
+ );
63
+ }
64
+
65
+ /** @type {import('@pdsjs/core/oauth').DpopProofResult} */
66
+ let parsed;
67
+ try {
68
+ parsed = await parseDpopProof(
69
+ proof,
70
+ request.method,
71
+ request.url,
72
+ jkt,
73
+ credential,
74
+ );
75
+ } catch (err) {
76
+ throw new SpaceTokenError(
77
+ err instanceof Error ? err.message : String(err),
78
+ 'BadDpopProof',
79
+ );
80
+ }
81
+
82
+ if (!firstUse(parsed.jti, Date.now())) {
83
+ throw new SpaceTokenError('DPoP proof replayed', 'BadDpopProof');
84
+ }
85
+ }
@@ -19,15 +19,69 @@ export declare function createAuthRoutes(ctx: {
19
19
  fetch?: typeof fetch;
20
20
  }): import('@pdsjs/core/pds').Routes;
21
21
  /**
22
- * Verify a space credential presented to a repo host.
22
+ * Build the read check the space endpoints share: the hosted account's own
23
+ * session, or a space credential for the space being read.
24
+ *
25
+ * @param {Object} ctx
26
+ * @param {() => Promise<string|null>} ctx.getDid
27
+ * @param {(did: string) => Promise<any>} ctx.resolveDid
28
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
29
+ * @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
30
+ * a Response to return, or null to proceed
31
+ */
32
+ export declare function createReadAuthorizer({ getDid, resolveDid, verifier }: {
33
+ getDid: () => Promise<string | null>;
34
+ resolveDid: (did: string) => Promise<any>;
35
+ verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
36
+ }): (request: Request, space: string, auth: {
37
+ did: string;
38
+ } | null) => Promise<Response | null>;
39
+ /**
40
+ * The space credential a request presents, or null.
41
+ *
42
+ * `DPoP`, never `Bearer`: a credential names the key it is bound to, and a
43
+ * presentation that proves nothing about that key is what the binding exists to
44
+ * refuse.
45
+ *
46
+ * @param {Request} request
47
+ * @returns {string|null}
48
+ */
49
+ export declare function credentialFromRequest(request: Request): string | null;
50
+ /**
51
+ * Check a presented space credential: the token itself, then the caller's
52
+ * possession of the key it names.
53
+ *
54
+ * The two questions are one function so that no endpoint accepting a credential
55
+ * can answer the first and forget the second.
23
56
  *
24
57
  * @param {Object} opts
58
+ * @param {Request} opts.request - the request the DPoP proof must cover
25
59
  * @param {string} opts.credential - the raw JWT
26
60
  * @param {string} opts.space - the space the request targets
27
61
  * @param {(did: string) => Promise<any>} opts.resolveDid
28
62
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
29
63
  * @returns {Promise<{iss: string}>}
30
64
  */
65
+ export declare function verifyPresentedCredential({ request, credential, space, resolveDid, verifier, }: {
66
+ request: Request;
67
+ credential: string;
68
+ space: string;
69
+ resolveDid: (did: string) => Promise<any>;
70
+ verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
71
+ }): Promise<{
72
+ iss: string;
73
+ }>;
74
+ /**
75
+ * Verify a space credential presented to a repo host.
76
+ *
77
+ * @param {Object} opts
78
+ * @param {string} opts.credential - the raw JWT
79
+ * @param {string} opts.space - the space the request targets
80
+ * @param {(did: string) => Promise<any>} opts.resolveDid
81
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
82
+ * @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
83
+ * holder must prove possession of
84
+ */
31
85
  export declare function verifySpaceCredential({ credential, space, resolveDid, verifier, }: {
32
86
  credential: string;
33
87
  space: string;
@@ -35,6 +89,7 @@ export declare function verifySpaceCredential({ credential, space, resolveDid, v
35
89
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
36
90
  }): Promise<{
37
91
  iss: string;
92
+ jkt: string;
38
93
  }>;
39
94
  /**
40
95
  * Ask a space's managing app whether to authorize a user.
@@ -15,6 +15,7 @@ import {
15
15
  spaceHostEndpoint,
16
16
  spaceSigningKey,
17
17
  } from '../authority.js';
18
+ import { verifyCredentialProof } from '../dpop.js';
18
19
  import { createServiceAuth } from '../service-auth.js';
19
20
  import {
20
21
  createSpaceToken,
@@ -142,7 +143,7 @@ export function createAuthRoutes(ctx) {
142
143
  handler: async (request) => {
143
144
  const body = await readJson(request);
144
145
  if (!body) return errorResponse('InvalidRequest', 'Invalid JSON body');
145
- const { space, clientAttestation } = body;
146
+ const { space, dpopJkt, clientAttestation } = body;
146
147
  if (typeof space !== 'string') {
147
148
  return errorResponse('InvalidRequest', 'space is required');
148
149
  }
@@ -181,6 +182,14 @@ export function createAuthRoutes(ctx) {
181
182
  return tokenErrorResponse(err);
182
183
  }
183
184
 
185
+ // Checked after the token, so a caller with no credentials at all reads
186
+ // 401 rather than a complaint about their body. Required, because an
187
+ // unbound credential is a bearer token for the whole space and every
188
+ // repo host in it would accept a replay of it.
189
+ if (typeof dpopJkt !== 'string' || !dpopJkt) {
190
+ return errorResponse('InvalidRequest', 'dpopJkt is required');
191
+ }
192
+
184
193
  // Structural validation only. Full verification means resolving the
185
194
  // client_id to its client-metadata.json, fetching the published JWKS,
186
195
  // and checking the signature against the key named by `kid`. Until then
@@ -253,40 +262,13 @@ export function createAuthRoutes(ctx) {
253
262
 
254
263
  const credential = await createSpaceToken(
255
264
  'credential',
256
- { iss: authorityDid, sub: space },
265
+ { iss: authorityDid, sub: space, dpopJkt },
257
266
  await getSigner(),
258
267
  );
259
268
  return Response.json({ credential });
260
269
  },
261
270
  },
262
271
 
263
- // Authority role: describe a space.
264
- '/xrpc/com.atproto.space.getSpace': {
265
- handler: async (_request, url) => {
266
- const space = url.searchParams.get('space');
267
- if (!space) return errorResponse('InvalidRequest', 'space is required');
268
- const row = await spaceStorage.getSpace(space);
269
- if (!row?.isOwner) {
270
- return errorResponse('SpaceNotFound', 'Space not found', 404);
271
- }
272
- return Response.json({
273
- uri: row.uri,
274
- config: {
275
- $type: 'com.atproto.simplespace.defs#spaceConfig',
276
- policy: row.policy,
277
- ...(row.managingApp ? { managingApp: row.managingApp } : {}),
278
- appAccess:
279
- row.appAccessType === 'allowList'
280
- ? {
281
- $type: 'com.atproto.simplespace.defs#allowList',
282
- allowed: row.appAllowed,
283
- }
284
- : { $type: 'com.atproto.simplespace.defs#open' },
285
- },
286
- });
287
- },
288
- },
289
-
290
272
  // Authority role: the writer set, which is the sync boundary. Accounts that
291
273
  // have written at least one record — not the broader set allowed to write,
292
274
  // which the authority may not even track.
@@ -338,15 +320,107 @@ function toJsonBytes(bytes) {
338
320
  }
339
321
 
340
322
  /**
341
- * Verify a space credential presented to a repo host.
323
+ * Build the read check the space endpoints share: the hosted account's own
324
+ * session, or a space credential for the space being read.
325
+ *
326
+ * @param {Object} ctx
327
+ * @param {() => Promise<string|null>} ctx.getDid
328
+ * @param {(did: string) => Promise<any>} ctx.resolveDid
329
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
330
+ * @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
331
+ * a Response to return, or null to proceed
332
+ */
333
+ export function createReadAuthorizer({ getDid, resolveDid, verifier }) {
334
+ return async (request, space, auth) => {
335
+ const hosted = await getDid();
336
+ if (auth && auth.did === hosted) return null;
337
+
338
+ const credential = credentialFromRequest(request);
339
+ if (!credential) {
340
+ return errorResponse(
341
+ 'AuthenticationRequired',
342
+ 'A session or space credential is required',
343
+ 401,
344
+ );
345
+ }
346
+ try {
347
+ await verifyPresentedCredential({
348
+ request,
349
+ credential,
350
+ space,
351
+ resolveDid,
352
+ verifier,
353
+ });
354
+ return null;
355
+ } catch (err) {
356
+ if (err instanceof SpaceTokenError) {
357
+ return errorResponse(err.code, err.message, 401);
358
+ }
359
+ throw err;
360
+ }
361
+ };
362
+ }
363
+
364
+ /**
365
+ * The space credential a request presents, or null.
366
+ *
367
+ * `DPoP`, never `Bearer`: a credential names the key it is bound to, and a
368
+ * presentation that proves nothing about that key is what the binding exists to
369
+ * refuse.
370
+ *
371
+ * @param {Request} request
372
+ * @returns {string|null}
373
+ */
374
+ export function credentialFromRequest(request) {
375
+ const match = (request.headers.get('authorization') ?? '').match(
376
+ /^DPoP\s+(.+)$/i,
377
+ );
378
+ return match ? match[1] : null;
379
+ }
380
+
381
+ /**
382
+ * Check a presented space credential: the token itself, then the caller's
383
+ * possession of the key it names.
384
+ *
385
+ * The two questions are one function so that no endpoint accepting a credential
386
+ * can answer the first and forget the second.
342
387
  *
343
388
  * @param {Object} opts
389
+ * @param {Request} opts.request - the request the DPoP proof must cover
344
390
  * @param {string} opts.credential - the raw JWT
345
391
  * @param {string} opts.space - the space the request targets
346
392
  * @param {(did: string) => Promise<any>} opts.resolveDid
347
393
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
348
394
  * @returns {Promise<{iss: string}>}
349
395
  */
396
+ export async function verifyPresentedCredential({
397
+ request,
398
+ credential,
399
+ space,
400
+ resolveDid,
401
+ verifier,
402
+ }) {
403
+ const { iss, jkt } = await verifySpaceCredential({
404
+ credential,
405
+ space,
406
+ resolveDid,
407
+ verifier,
408
+ });
409
+ await verifyCredentialProof({ request, credential, jkt });
410
+ return { iss };
411
+ }
412
+
413
+ /**
414
+ * Verify a space credential presented to a repo host.
415
+ *
416
+ * @param {Object} opts
417
+ * @param {string} opts.credential - the raw JWT
418
+ * @param {string} opts.space - the space the request targets
419
+ * @param {(did: string) => Promise<any>} opts.resolveDid
420
+ * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
421
+ * @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
422
+ * holder must prove possession of
423
+ */
350
424
  export async function verifySpaceCredential({
351
425
  credential,
352
426
  space,
@@ -373,7 +447,14 @@ export async function verifySpaceCredential({
373
447
  'BadJwtIss',
374
448
  );
375
449
  }
376
- return { iss: payload.iss };
450
+ // parseSpaceToken refuses a credential without one, so this is reachable only
451
+ // if that check is relaxed. It stays because the cost of it being wrong is a
452
+ // credential for a whole space that anyone holding a copy may present.
453
+ const jkt = payload.cnf?.jkt;
454
+ if (!jkt) {
455
+ throw new SpaceTokenError('Credential is not bound to a key', 'BadJwtCnf');
456
+ }
457
+ return { iss: payload.iss, jkt };
377
458
  }
378
459
 
379
460
  /**