@pdsjs/spaces 2.0.2 → 2.0.5

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.2",
3
+ "version": "2.0.5",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -20,10 +20,11 @@
20
20
  "./car": "./src/car.js",
21
21
  "./token": "./src/token.js",
22
22
  "./authority": "./src/authority.js",
23
- "./service-auth": "./src/service-auth.js"
23
+ "./service-auth": "./src/service-auth.js",
24
+ "./admin": "./src/admin.js"
24
25
  },
25
26
  "dependencies": {
26
- "@pdsjs/core": "2.2.0"
27
+ "@pdsjs/core": "2.4.1"
27
28
  },
28
29
  "publishConfig": {
29
30
  "access": "public"
package/src/admin.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @param {Object} ctx
3
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
+ * @param {() => Promise<string|null>} ctx.getDid
5
+ * @returns {import('@pdsjs/core/ports').SpaceAdminPort}
6
+ */
7
+ export declare function createSpaceAdmin({ spaceStorage, getDid }: {
8
+ spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
9
+ getDid: () => Promise<string | null>;
10
+ }): import('@pdsjs/core/ports').SpaceAdminPort;
package/src/admin.js ADDED
@@ -0,0 +1,115 @@
1
+ // @pdsjs/spaces/admin - space management for the account page.
2
+ //
3
+ // The account page runs behind the owner's own session, so these skip the
4
+ // scope checks the XRPC management endpoints apply to third-party callers.
5
+ // They mutate the same storage the same way com.atproto.simplespace.* does.
6
+
7
+ import { cborDecode, createTid } from '@pdsjs/core/repo';
8
+ import { LtHash } from './lthash.js';
9
+ import { makeSpaceRow } from './space-row.js';
10
+ import { formatSpaceUri } from './uri.js';
11
+ import { applyWrites } from './writer.js';
12
+
13
+ /**
14
+ * @param {Object} ctx
15
+ * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
16
+ * @param {() => Promise<string|null>} ctx.getDid
17
+ * @returns {import('@pdsjs/core/ports').SpaceAdminPort}
18
+ */
19
+ export function createSpaceAdmin({ spaceStorage, getDid }) {
20
+ /** @returns {Promise<string>} */
21
+ async function requireDid() {
22
+ const did = await getDid();
23
+ if (!did) throw new Error('server not initialised');
24
+ return did;
25
+ }
26
+
27
+ /**
28
+ * @param {string} space
29
+ * @returns {Promise<void>}
30
+ */
31
+ async function requireOwnedSpace(space) {
32
+ const row = await spaceStorage.getSpace(space);
33
+ if (!row?.isOwner || row.deletedAt) {
34
+ throw new Error(`no such space: ${space}`);
35
+ }
36
+ }
37
+
38
+ return {
39
+ async createSpace({ type, skey }) {
40
+ if (!type.includes('.')) {
41
+ throw new Error(`space type must be an NSID: ${type}`);
42
+ }
43
+ if (skey && skey.length > 512) {
44
+ throw new Error('skey is too long');
45
+ }
46
+ const did = await requireDid();
47
+ const uri = formatSpaceUri({
48
+ spaceDid: did,
49
+ spaceType: type,
50
+ skey: skey || createTid(),
51
+ });
52
+ if (await spaceStorage.getSpace(uri)) {
53
+ throw new Error(`space already exists: ${uri}`);
54
+ }
55
+ await spaceStorage.putSpace(makeSpaceRow(uri, { isOwner: true }));
56
+ // The owner joins their own space: a member-list space checks membership
57
+ // before it issues a credential, and that is the default policy.
58
+ await spaceStorage.addMember(uri, did);
59
+ return { uri };
60
+ },
61
+
62
+ async addMember(space, did) {
63
+ if (!did.startsWith('did:')) {
64
+ throw new Error(`not a DID: ${did}`);
65
+ }
66
+ await requireOwnedSpace(space);
67
+ await spaceStorage.addMember(space, did);
68
+ },
69
+
70
+ async removeMember(space, did) {
71
+ const owner = await requireDid();
72
+ if (did === owner) {
73
+ throw new Error('the owner cannot leave their own space');
74
+ }
75
+ await requireOwnedSpace(space);
76
+ await spaceStorage.removeMember(space, did);
77
+ },
78
+
79
+ async getRecord(space, collection, rkey) {
80
+ await requireOwnedSpace(space);
81
+ const row = await spaceStorage.getSpaceRecord(space, collection, rkey);
82
+ return row ? /** @type {Object} */ (cborDecode(row.value)) : null;
83
+ },
84
+
85
+ async createRecord(space, collection, rkey, record) {
86
+ await writeRecord(space, 'create', collection, rkey, record);
87
+ },
88
+
89
+ async putRecord(space, collection, rkey, record) {
90
+ await writeRecord(space, 'put', collection, rkey, record);
91
+ },
92
+ };
93
+
94
+ /**
95
+ * @param {string} space
96
+ * @param {'create'|'put'} action
97
+ * @param {string} collection
98
+ * @param {string} rkey
99
+ * @param {Object} record
100
+ * @returns {Promise<void>}
101
+ */
102
+ async function writeRecord(space, action, collection, rkey, record) {
103
+ const did = await requireDid();
104
+ await requireOwnedSpace(space);
105
+ const commit = await applyWrites(spaceStorage, {
106
+ space,
107
+ writes: [{ action, collection, rkey, record }],
108
+ });
109
+ // What fireNotifyWrite does after an XRPC write into the owner's own
110
+ // space: record the owner in the writer set, so listRepos knows this
111
+ // repo before its first push.
112
+ const hash = await new LtHash(commit.setHash).digest();
113
+ await spaceStorage.putSpaceWriter(space, did, commit.rev, hash);
114
+ }
115
+ }
@@ -51,7 +51,7 @@ export declare function spaceHostAudience(spaceDid: string): string;
51
51
  /**
52
52
  * Build the `getSigningKey` callback verifySpaceToken expects.
53
53
  *
54
- * @param {(did: string) => Promise<any>} resolveDid
55
- * @returns {(iss: string, kid?: string) => Promise<string>}
54
+ * @param {import('@pdsjs/core/ports').DidResolverPort} resolveDid
55
+ * @returns {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>}
56
56
  */
57
- export declare function didKeyResolver(resolveDid: (did: string) => Promise<any>): (iss: string, kid?: string) => Promise<string>;
57
+ export declare function didKeyResolver(resolveDid: import('@pdsjs/core/ports').DidResolverPort): (iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>;
package/src/authority.js CHANGED
@@ -150,12 +150,12 @@ export function spaceHostAudience(spaceDid) {
150
150
  /**
151
151
  * Build the `getSigningKey` callback verifySpaceToken expects.
152
152
  *
153
- * @param {(did: string) => Promise<any>} resolveDid
154
- * @returns {(iss: string, kid?: string) => Promise<string>}
153
+ * @param {import('@pdsjs/core/ports').DidResolverPort} resolveDid
154
+ * @returns {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>}
155
155
  */
156
156
  export function didKeyResolver(resolveDid) {
157
- return async (iss, kid) => {
158
- const didDoc = await resolveDid(iss);
157
+ return async (iss, kid, forceRefresh = false) => {
158
+ const didDoc = await resolveDid(iss, { forceRefresh });
159
159
  if (!didDoc) {
160
160
  throw new SpaceAuthorityError(`Could not resolve ${iss}`, 'DidNotFound');
161
161
  }
package/src/dpop.d.ts CHANGED
@@ -1,3 +1,19 @@
1
+ /**
2
+ * The key an application asks a credential to be bound to, read from the proof
3
+ * it presents when it asks.
4
+ *
5
+ * A thumbprint named in the request body is a claim: any holder of the
6
+ * delegation token can make it about a key somebody else controls. A proof
7
+ * demonstrates possession of the key it names. The proof carries no `ath`,
8
+ * because a delegation token is an authorization grant rather than an access
9
+ * token, so there is no bound token to hash.
10
+ *
11
+ * @param {Request} request
12
+ * @returns {Promise<string|null>} the proof's thumbprint, or null when the
13
+ * request carries no proof
14
+ * @throws {SpaceTokenError} when a proof is present but invalid or replayed
15
+ */
16
+ export declare function credentialBindingFromProof(request: Request): Promise<string | null>;
1
17
  /**
2
18
  * Check the DPoP proof presented with a space credential.
3
19
  *
package/src/dpop.js CHANGED
@@ -43,6 +43,42 @@ function firstUse(jti, now) {
43
43
  return true;
44
44
  }
45
45
 
46
+ /**
47
+ * The key an application asks a credential to be bound to, read from the proof
48
+ * it presents when it asks.
49
+ *
50
+ * A thumbprint named in the request body is a claim: any holder of the
51
+ * delegation token can make it about a key somebody else controls. A proof
52
+ * demonstrates possession of the key it names. The proof carries no `ath`,
53
+ * because a delegation token is an authorization grant rather than an access
54
+ * token, so there is no bound token to hash.
55
+ *
56
+ * @param {Request} request
57
+ * @returns {Promise<string|null>} the proof's thumbprint, or null when the
58
+ * request carries no proof
59
+ * @throws {SpaceTokenError} when a proof is present but invalid or replayed
60
+ */
61
+ export async function credentialBindingFromProof(request) {
62
+ const proof = request.headers.get('dpop');
63
+ if (!proof) return null;
64
+
65
+ /** @type {import('@pdsjs/core/oauth').DpopProofResult} */
66
+ let parsed;
67
+ try {
68
+ parsed = await parseDpopProof(proof, request.method, request.url);
69
+ } catch (err) {
70
+ throw new SpaceTokenError(
71
+ err instanceof Error ? err.message : String(err),
72
+ 'BadDpopProof',
73
+ );
74
+ }
75
+
76
+ if (!firstUse(parsed.jti, Date.now())) {
77
+ throw new SpaceTokenError('DPoP proof replayed', 'BadDpopProof');
78
+ }
79
+ return parsed.jkt;
80
+ }
81
+
46
82
  /**
47
83
  * Check the DPoP proof presented with a space credential.
48
84
  *
@@ -2,8 +2,8 @@
2
2
  * @param {Object} ctx
3
3
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
4
  * @param {() => Promise<string|null>} ctx.getDid
5
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
- * @param {(did: string) => Promise<any>} ctx.resolveDid
5
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
6
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
7
7
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
8
8
  * @param {typeof fetch} [ctx.fetch]
9
9
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -11,10 +11,8 @@
11
11
  export declare function createAuthRoutes(ctx: {
12
12
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
13
13
  getDid: () => Promise<string | null>;
14
- getSigner: () => Promise<{
15
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
16
- }>;
17
- resolveDid: (did: string) => Promise<any>;
14
+ getSigner: () => Promise<import('../token.js').SpaceSigner>;
15
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
18
16
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
19
17
  fetch?: typeof fetch;
20
18
  }): import('@pdsjs/core/pds').Routes;
@@ -24,14 +22,14 @@ export declare function createAuthRoutes(ctx: {
24
22
  *
25
23
  * @param {Object} ctx
26
24
  * @param {() => Promise<string|null>} ctx.getDid
27
- * @param {(did: string) => Promise<any>} ctx.resolveDid
25
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
28
26
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
29
27
  * @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
30
28
  * a Response to return, or null to proceed
31
29
  */
32
30
  export declare function createReadAuthorizer({ getDid, resolveDid, verifier }: {
33
31
  getDid: () => Promise<string | null>;
34
- resolveDid: (did: string) => Promise<any>;
32
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
35
33
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
36
34
  }): (request: Request, space: string, auth: {
37
35
  did: string;
@@ -58,7 +56,7 @@ export declare function credentialFromRequest(request: Request): string | null;
58
56
  * @param {Request} opts.request - the request the DPoP proof must cover
59
57
  * @param {string} opts.credential - the raw JWT
60
58
  * @param {string} opts.space - the space the request targets
61
- * @param {(did: string) => Promise<any>} opts.resolveDid
59
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
62
60
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
63
61
  * @returns {Promise<{iss: string}>}
64
62
  */
@@ -66,7 +64,7 @@ export declare function verifyPresentedCredential({ request, credential, space,
66
64
  request: Request;
67
65
  credential: string;
68
66
  space: string;
69
- resolveDid: (did: string) => Promise<any>;
67
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
70
68
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
71
69
  }): Promise<{
72
70
  iss: string;
@@ -77,7 +75,7 @@ export declare function verifyPresentedCredential({ request, credential, space,
77
75
  * @param {Object} opts
78
76
  * @param {string} opts.credential - the raw JWT
79
77
  * @param {string} opts.space - the space the request targets
80
- * @param {(did: string) => Promise<any>} opts.resolveDid
78
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
81
79
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
82
80
  * @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
83
81
  * holder must prove possession of
@@ -85,7 +83,7 @@ export declare function verifyPresentedCredential({ request, credential, space,
85
83
  export declare function verifySpaceCredential({ credential, space, resolveDid, verifier, }: {
86
84
  credential: string;
87
85
  space: string;
88
- resolveDid: (did: string) => Promise<any>;
86
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
89
87
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
90
88
  }): Promise<{
91
89
  iss: string;
@@ -103,8 +101,8 @@ export declare function verifySpaceCredential({ credential, space, resolveDid, v
103
101
  * @param {string} opts.space
104
102
  * @param {string} opts.userDid
105
103
  * @param {string|undefined} opts.clientId
106
- * @param {(did: string) => Promise<any>} opts.resolveDid
107
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} opts.getSigner
104
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
105
+ * @param {() => Promise<import('../token.js').SpaceSigner>} opts.getSigner
108
106
  * @param {typeof fetch} [opts.fetch]
109
107
  * @returns {Promise<boolean>}
110
108
  */
@@ -114,9 +112,7 @@ export declare function checkUserAccess({ managingApp, authorityDid, space, user
114
112
  space: string;
115
113
  userDid: string;
116
114
  clientId: string | undefined;
117
- resolveDid: (did: string) => Promise<any>;
118
- getSigner: () => Promise<{
119
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
120
- }>;
115
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
116
+ getSigner: () => Promise<import('../token.js').SpaceSigner>;
121
117
  fetch?: typeof fetch;
122
118
  }): Promise<boolean>;
@@ -15,7 +15,7 @@ import {
15
15
  spaceHostEndpoint,
16
16
  spaceSigningKey,
17
17
  } from '../authority.js';
18
- import { verifyCredentialProof } from '../dpop.js';
18
+ import { credentialBindingFromProof, verifyCredentialProof } from '../dpop.js';
19
19
  import { createServiceAuth } from '../service-auth.js';
20
20
  import {
21
21
  createSpaceToken,
@@ -62,8 +62,8 @@ async function readJson(request) {
62
62
  * @param {Object} ctx
63
63
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
64
64
  * @param {() => Promise<string|null>} ctx.getDid
65
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
66
- * @param {(did: string) => Promise<any>} ctx.resolveDid
65
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
66
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
67
67
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
68
68
  * @param {typeof fetch} [ctx.fetch]
69
69
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -183,11 +183,35 @@ export function createAuthRoutes(ctx) {
183
183
  }
184
184
 
185
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
186
+ // 401 rather than a complaint about their request. Required, because an
187
187
  // unbound credential is a bearer token for the whole space and every
188
188
  // repo host in it would accept a replay of it.
189
- if (typeof dpopJkt !== 'string' || !dpopJkt) {
190
- return errorResponse('InvalidRequest', 'dpopJkt is required');
189
+ //
190
+ // The key arrives two ways. A DPoP proof demonstrates it, which is what
191
+ // the Rust implementation sends; a `dpopJkt` body field asserts it,
192
+ // which is what the reference implementation reads. Both are accepted,
193
+ // and a caller sending both must agree with itself.
194
+ /** @type {string|null} */
195
+ let boundJkt;
196
+ try {
197
+ boundJkt = await credentialBindingFromProof(request);
198
+ } catch (err) {
199
+ return tokenErrorResponse(err);
200
+ }
201
+ if (typeof dpopJkt === 'string' && dpopJkt) {
202
+ if (boundJkt && boundJkt !== dpopJkt) {
203
+ return errorResponse(
204
+ 'InvalidRequest',
205
+ 'dpopJkt names a different key than the DPoP proof proves',
206
+ );
207
+ }
208
+ boundJkt ??= dpopJkt;
209
+ }
210
+ if (!boundJkt) {
211
+ return errorResponse(
212
+ 'InvalidRequest',
213
+ 'A DPoP proof or a dpopJkt is required',
214
+ );
191
215
  }
192
216
 
193
217
  // Structural validation only. Full verification means resolving the
@@ -218,9 +242,12 @@ export function createAuthRoutes(ctx) {
218
242
  return errorResponse('SpaceDeleted', 'Space has been deleted');
219
243
  }
220
244
 
221
- // User perimeter.
245
+ // User perimeter. The authority is the only party who can reconfigure
246
+ // the space, so no policy may lock it out of its own space.
222
247
  let userAuthorized;
223
- if (spaceRow.policy === 'public') {
248
+ if (userDid === authorityDid) {
249
+ userAuthorized = true;
250
+ } else if (spaceRow.policy === 'public') {
224
251
  userAuthorized = true;
225
252
  } else if (spaceRow.policy === 'member-list') {
226
253
  userAuthorized = await spaceStorage.isMember(space, userDid);
@@ -262,7 +289,7 @@ export function createAuthRoutes(ctx) {
262
289
 
263
290
  const credential = await createSpaceToken(
264
291
  'credential',
265
- { iss: authorityDid, sub: space, dpopJkt },
292
+ { iss: authorityDid, sub: space, dpopJkt: boundJkt },
266
293
  await getSigner(),
267
294
  );
268
295
  return Response.json({ credential });
@@ -325,7 +352,7 @@ function toJsonBytes(bytes) {
325
352
  *
326
353
  * @param {Object} ctx
327
354
  * @param {() => Promise<string|null>} ctx.getDid
328
- * @param {(did: string) => Promise<any>} ctx.resolveDid
355
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
329
356
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
330
357
  * @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
331
358
  * a Response to return, or null to proceed
@@ -389,7 +416,7 @@ export function credentialFromRequest(request) {
389
416
  * @param {Request} opts.request - the request the DPoP proof must cover
390
417
  * @param {string} opts.credential - the raw JWT
391
418
  * @param {string} opts.space - the space the request targets
392
- * @param {(did: string) => Promise<any>} opts.resolveDid
419
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
393
420
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
394
421
  * @returns {Promise<{iss: string}>}
395
422
  */
@@ -416,7 +443,7 @@ export async function verifyPresentedCredential({
416
443
  * @param {Object} opts
417
444
  * @param {string} opts.credential - the raw JWT
418
445
  * @param {string} opts.space - the space the request targets
419
- * @param {(did: string) => Promise<any>} opts.resolveDid
446
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
420
447
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
421
448
  * @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
422
449
  * holder must prove possession of
@@ -469,8 +496,8 @@ export async function verifySpaceCredential({
469
496
  * @param {string} opts.space
470
497
  * @param {string} opts.userDid
471
498
  * @param {string|undefined} opts.clientId
472
- * @param {(did: string) => Promise<any>} opts.resolveDid
473
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} opts.getSigner
499
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
500
+ * @param {() => Promise<import('../token.js').SpaceSigner>} opts.getSigner
474
501
  * @param {typeof fetch} [opts.fetch]
475
502
  * @returns {Promise<boolean>}
476
503
  */
@@ -2,8 +2,8 @@
2
2
  * @param {Object} ctx
3
3
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
4
  * @param {() => Promise<string|null>} ctx.getDid
5
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
- * @param {(did: string) => Promise<any>} ctx.resolveDid
5
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
6
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
7
7
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
8
8
  * @param {typeof fetch} [ctx.fetch]
9
9
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -11,10 +11,8 @@
11
11
  export declare function createManageRoutes(ctx: {
12
12
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
13
13
  getDid: () => Promise<string | null>;
14
- getSigner: () => Promise<{
15
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
16
- }>;
17
- resolveDid: (did: string) => Promise<any>;
14
+ getSigner: () => Promise<import('../token.js').SpaceSigner>;
15
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
18
16
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
19
17
  fetch?: typeof fetch;
20
18
  }): import('@pdsjs/core/pds').Routes;
@@ -212,8 +212,8 @@ async function readJson(request) {
212
212
  * @param {Object} ctx
213
213
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
214
214
  * @param {() => Promise<string|null>} ctx.getDid
215
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
216
- * @param {(did: string) => Promise<any>} ctx.resolveDid
215
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
216
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
217
217
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
218
218
  * @param {typeof fetch} [ctx.fetch]
219
219
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -745,7 +745,7 @@ export function createManageRoutes(ctx) {
745
745
  * @param {Object} ctx
746
746
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
747
747
  * @param {() => Promise<string|null>} ctx.getDid
748
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
748
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
749
749
  * @param {typeof fetch} [ctx.fetch]
750
750
  * @param {string} space
751
751
  * @returns {Promise<void>}
@@ -3,8 +3,8 @@
3
3
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
4
4
  * @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs
5
5
  * @param {() => Promise<string|null>} ctx.getDid
6
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
7
- * @param {(did: string) => Promise<any>} ctx.resolveDid
6
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
7
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
8
8
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
9
9
  * @returns {import('@pdsjs/core/pds').Routes}
10
10
  */
@@ -12,9 +12,7 @@ export declare function createReadRoutes(ctx: {
12
12
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
13
13
  blobs: import('@pdsjs/core/ports').BlobPort;
14
14
  getDid: () => Promise<string | null>;
15
- getSigner: () => Promise<{
16
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
17
- }>;
18
- resolveDid: (did: string) => Promise<any>;
15
+ getSigner: () => Promise<import('../token.js').SpaceSigner>;
16
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
19
17
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
20
18
  }): import('@pdsjs/core/pds').Routes;
@@ -141,8 +141,8 @@ async function spaceBlobRefs(spaceStorage, space) {
141
141
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
142
142
  * @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs
143
143
  * @param {() => Promise<string|null>} ctx.getDid
144
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
145
- * @param {(did: string) => Promise<any>} ctx.resolveDid
144
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
145
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
146
146
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
147
147
  * @returns {import('@pdsjs/core/pds').Routes}
148
148
  */
@@ -4,8 +4,9 @@
4
4
  * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
5
5
  * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
6
6
  * @param {() => Promise<string|null>} ctx.getDid
7
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
8
- * @param {(did: string) => Promise<any>} ctx.resolveDid
7
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
8
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
9
+ * @param {{collections: string[], check: (write: {space: string|null, collection: string, rkey: string, prevValue: unknown|null, nextValue: unknown|null}) => Promise<void>}} [ctx.recordGuard]
9
10
  * @param {typeof fetch} [ctx.fetch]
10
11
  * @returns {import('@pdsjs/core/pds').Routes}
11
12
  */
@@ -14,9 +15,17 @@ export declare function createWriteRoutes(ctx: {
14
15
  linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
15
16
  unlinkBlobs: (recordUri: string) => Promise<void>;
16
17
  getDid: () => Promise<string | null>;
17
- getSigner: () => Promise<{
18
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
19
- }>;
20
- resolveDid: (did: string) => Promise<any>;
18
+ getSigner: () => Promise<import('../token.js').SpaceSigner>;
19
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
20
+ recordGuard?: {
21
+ collections: string[];
22
+ check: (write: {
23
+ space: string | null;
24
+ collection: string;
25
+ rkey: string;
26
+ prevValue: unknown | null;
27
+ nextValue: unknown | null;
28
+ }) => Promise<void>;
29
+ };
21
30
  fetch?: typeof fetch;
22
31
  }): import('@pdsjs/core/pds').Routes;
@@ -7,7 +7,12 @@
7
7
  // Authorization is two checks: the caller may only write its own repo, and the
8
8
  // token's `space:` scope must cover the target space, action and collection.
9
9
 
10
- import { createTid, findBlobRefs, recordTimeOf } from '@pdsjs/core/repo';
10
+ import {
11
+ cborDecode,
12
+ createTid,
13
+ findBlobRefs,
14
+ recordTimeOf,
15
+ } from '@pdsjs/core/repo';
11
16
  import { ScopePermissions } from '@pdsjs/core/scope';
12
17
  import { spaceHostEndpoint } from '../authority.js';
13
18
  import { LtHash } from '../lthash.js';
@@ -170,14 +175,58 @@ function writeAction(write) {
170
175
  * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
171
176
  * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
172
177
  * @param {() => Promise<string|null>} ctx.getDid
173
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
174
- * @param {(did: string) => Promise<any>} ctx.resolveDid
178
+ * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
179
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
180
+ * @param {{collections: string[], check: (write: {space: string|null, collection: string, rkey: string, prevValue: unknown|null, nextValue: unknown|null}) => Promise<void>}} [ctx.recordGuard]
175
181
  * @param {typeof fetch} [ctx.fetch]
176
182
  * @returns {import('@pdsjs/core/pds').Routes}
177
183
  */
178
184
  export function createWriteRoutes(ctx) {
179
- const { spaceStorage, linkBlob, unlinkBlobs, getDid, getSigner, resolveDid } =
180
- ctx;
185
+ const {
186
+ spaceStorage,
187
+ linkBlob,
188
+ unlinkBlobs,
189
+ getDid,
190
+ getSigner,
191
+ resolveDid,
192
+ recordGuard,
193
+ } = ctx;
194
+
195
+ /**
196
+ * Consult the write guard for one write in a collection it covers. Null to
197
+ * proceed, the refusal Response otherwise. Read-then-check races the write
198
+ * queue; the single account that writes here makes that a non-event.
199
+ * @param {string} space
200
+ * @param {import('../writer.js').SpaceWriteInput} write
201
+ * @returns {Promise<Response|null>}
202
+ */
203
+ async function guardWrite(space, write) {
204
+ if (!recordGuard || !recordGuard.collections.includes(write.collection)) {
205
+ return null;
206
+ }
207
+ const prev = await spaceStorage.getSpaceRecord(
208
+ space,
209
+ write.collection,
210
+ write.rkey,
211
+ );
212
+ try {
213
+ await recordGuard.check({
214
+ space,
215
+ collection: write.collection,
216
+ rkey: write.rkey,
217
+ prevValue: prev ? cborDecode(prev.value) : null,
218
+ nextValue: write.action === 'delete' ? null : (write.record ?? null),
219
+ });
220
+ return null;
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ const code =
224
+ err instanceof Error && 'code' in err
225
+ ? String(/** @type {{code: unknown}} */ (err).code)
226
+ : 'InvalidRequest';
227
+ return errorResponse(code, message);
228
+ }
229
+ }
181
230
 
182
231
  /**
183
232
  * Record which blobs a space write leaves referenced.
@@ -317,6 +366,9 @@ export function createWriteRoutes(ctx) {
317
366
  });
318
367
  if (scopeError) return scopeError;
319
368
 
369
+ const refused = await guardWrite(body.space, write);
370
+ if (refused) return refused;
371
+
320
372
  try {
321
373
  const commit = await applyWrites(spaceStorage, {
322
374
  space: body.space,
@@ -374,11 +426,28 @@ export function createWriteRoutes(ctx) {
374
426
  if (body.record === undefined) {
375
427
  throw new SpaceWriteError('record is required', 'InvalidRequest');
376
428
  }
429
+ if (
430
+ body.swapRecord !== undefined &&
431
+ body.swapRecord !== null &&
432
+ typeof body.swapRecord !== 'string'
433
+ ) {
434
+ throw new SpaceWriteError(
435
+ 'swapRecord must be a CID string or null',
436
+ 'InvalidRequest',
437
+ );
438
+ }
377
439
  return {
378
440
  action: 'put',
379
441
  collection: body.collection,
380
442
  rkey: body.rkey,
381
443
  record: body.record,
444
+ // A pds.js extension the draft proposal does not have: null
445
+ // requires that the record does not exist, a CID requires that
446
+ // exact version, absent skips the check. A server without it
447
+ // ignores the field, so a client falls back to last-write-wins.
448
+ ...(body.swapRecord !== undefined
449
+ ? { swapCid: body.swapRecord }
450
+ : {}),
382
451
  };
383
452
  },
384
453
  (body, result, did) => ({
@@ -464,6 +533,10 @@ export function createWriteRoutes(ctx) {
464
533
  });
465
534
  if (scopeError) return scopeError;
466
535
  }
536
+ for (const w of writes) {
537
+ const refused = await guardWrite(body.space, w);
538
+ if (refused) return refused;
539
+ }
467
540
 
468
541
  try {
469
542
  const commit = await applyWrites(spaceStorage, {
package/src/notify.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * @param {Object} ctx
15
15
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
16
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
16
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
17
17
  * @param {typeof fetch} [ctx.fetch]
18
18
  * @param {Object} notice
19
19
  * @param {string} notice.authorityDid - the space's authority, which signs
@@ -25,9 +25,7 @@
25
25
  */
26
26
  export declare function forwardToSyncers(ctx: {
27
27
  spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
28
- getSigner: () => Promise<{
29
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
30
- }>;
28
+ getSigner: () => Promise<import('./token.js').SpaceSigner>;
31
29
  fetch?: typeof fetch;
32
30
  }, { authorityDid, space, repo, rev, hash }: {
33
31
  authorityDid: string;
package/src/notify.js CHANGED
@@ -23,7 +23,7 @@ import { createServiceAuth } from './service-auth.js';
23
23
  *
24
24
  * @param {Object} ctx
25
25
  * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
26
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
26
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
27
27
  * @param {typeof fetch} [ctx.fetch]
28
28
  * @param {Object} notice
29
29
  * @param {string} notice.authorityDid - the space's authority, which signs
package/src/routes.d.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
7
7
  * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
8
8
  * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
9
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
9
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
10
10
  * - the account's signing key, used to sign repo commits and space tokens
11
- * @param {(did: string) => Promise<any>} ctx.resolveDid - DID document lookup,
11
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid - DID document lookup,
12
12
  * injected so this package stays dependency-free
13
13
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
14
+ * @param {import('@pdsjs/core/ports').RecordWriteGuardPort} [ctx.recordGuard]
15
+ * - a veto over writes in its named collections, e.g. git branch protection
14
16
  * @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
15
17
  * calls, so tests can observe them without a network
16
18
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -21,10 +23,9 @@ export declare function createSpaceRoutes(ctx: {
21
23
  linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
22
24
  unlinkBlobs: (recordUri: string) => Promise<void>;
23
25
  getDid: () => Promise<string | null>;
24
- getSigner: () => Promise<{
25
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
26
- }>;
27
- resolveDid: (did: string) => Promise<any>;
26
+ getSigner: () => Promise<import('./token.js').SpaceSigner>;
27
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
28
28
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
29
+ recordGuard?: import('@pdsjs/core/ports').RecordWriteGuardPort;
29
30
  fetch?: typeof fetch;
30
31
  }): import('@pdsjs/core/pds').Routes;
package/src/routes.js CHANGED
@@ -18,11 +18,13 @@ import { createWriteRoutes } from './handlers/write.js';
18
18
  * @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
19
19
  * @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
20
20
  * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
21
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
21
+ * @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
22
22
  * - the account's signing key, used to sign repo commits and space tokens
23
- * @param {(did: string) => Promise<any>} ctx.resolveDid - DID document lookup,
23
+ * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid - DID document lookup,
24
24
  * injected so this package stays dependency-free
25
25
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
26
+ * @param {import('@pdsjs/core/ports').RecordWriteGuardPort} [ctx.recordGuard]
27
+ * - a veto over writes in its named collections, e.g. git branch protection
26
28
  * @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
27
29
  * calls, so tests can observe them without a network
28
30
  * @returns {import('@pdsjs/core/pds').Routes}
@@ -5,7 +5,7 @@
5
5
  * @param {string} opts.jwt
6
6
  * @param {string} opts.aud - our own DID; the token must be addressed to us
7
7
  * @param {string} opts.lxm - the method being called
8
- * @param {(did: string) => Promise<any>} opts.resolveDid
8
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
9
9
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
10
10
  * @returns {Promise<{iss: string}>}
11
11
  */
@@ -13,7 +13,7 @@ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier,
13
13
  jwt: string;
14
14
  aud: string;
15
15
  lxm: string;
16
- resolveDid: (did: string) => Promise<any>;
16
+ resolveDid: import('@pdsjs/core/ports').DidResolverPort;
17
17
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
18
18
  }): Promise<{
19
19
  iss: string;
@@ -29,14 +29,12 @@ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier,
29
29
  * @param {string} opts.iss - our DID
30
30
  * @param {string} opts.aud - the service we are calling
31
31
  * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
32
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
32
+ * @param {import('./token.js').SpaceSigner} opts.signer
33
33
  * @returns {Promise<string>}
34
34
  */
35
35
  export declare function createServiceAuth({ iss, aud, lxm, signer }: {
36
36
  iss: string;
37
37
  aud: string;
38
38
  lxm: string;
39
- signer: {
40
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
41
- };
39
+ signer: import('./token.js').SpaceSigner;
42
40
  }): Promise<string>;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
9
9
  import { atprotoSigningKey } from './authority.js';
10
- import { SpaceTokenError } from './token.js';
10
+ import { assertKeyAlg, SpaceTokenError, signerAlg } from './token.js';
11
11
 
12
12
  const CLOCK_SKEW_SEC = 5;
13
13
 
@@ -43,7 +43,7 @@ function decodeSig(b64) {
43
43
  * @param {string} opts.jwt
44
44
  * @param {string} opts.aud - our own DID; the token must be addressed to us
45
45
  * @param {string} opts.lxm - the method being called
46
- * @param {(did: string) => Promise<any>} opts.resolveDid
46
+ * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
47
47
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
48
48
  * @returns {Promise<{iss: string}>}
49
49
  */
@@ -60,14 +60,19 @@ export async function verifyServiceAuth({
60
60
  }
61
61
  const [headerB64, payloadB64, sigB64] = parts;
62
62
 
63
+ /** @type {any} */
64
+ let header;
63
65
  /** @type {any} */
64
66
  let payload;
65
67
  try {
66
- decodePart(headerB64);
68
+ header = decodePart(headerB64);
67
69
  payload = decodePart(payloadB64);
68
70
  } catch {
69
71
  throw new SpaceTokenError('could not parse service token', 'BadJwt');
70
72
  }
73
+ if (typeof header?.alg !== 'string' || !header.alg) {
74
+ throw new SpaceTokenError('missing service token "alg"', 'BadJwt');
75
+ }
71
76
 
72
77
  const now = Math.floor(Date.now() / 1000);
73
78
  if (typeof payload.exp !== 'number' || now - CLOCK_SKEW_SEC >= payload.exp) {
@@ -91,30 +96,47 @@ export async function verifyServiceAuth({
91
96
  );
92
97
  }
93
98
 
94
- const didDoc = await resolveDid(payload.iss);
95
- if (!didDoc) {
96
- throw new SpaceTokenError(
97
- `could not resolve ${payload.iss}`,
98
- 'DidNotFound',
99
- );
100
- }
101
- const didKey = atprotoSigningKey(didDoc, payload.iss);
102
99
  const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
100
+ const sig = decodeSig(sigB64);
103
101
 
104
- let valid;
105
- try {
106
- valid = await verifier.verify(didKey, signingInput, decodeSig(sigB64));
107
- } catch (err) {
108
- throw new SpaceTokenError(
109
- `could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
110
- 'BadJwtSignature',
111
- );
112
- }
113
- if (!valid) {
114
- throw new SpaceTokenError(
115
- 'invalid service token signature',
116
- 'BadJwtSignature',
117
- );
102
+ /**
103
+ * @param {boolean} forceRefresh
104
+ * @returns {Promise<{didKey: string, valid: boolean}>}
105
+ */
106
+ const matchesSignature = async (forceRefresh) => {
107
+ const didDoc = await resolveDid(payload.iss, { forceRefresh });
108
+ if (!didDoc) {
109
+ throw new SpaceTokenError(
110
+ `could not resolve ${payload.iss}`,
111
+ 'DidNotFound',
112
+ );
113
+ }
114
+ const didKey = atprotoSigningKey(didDoc, payload.iss);
115
+ assertKeyAlg(didKey, header.alg);
116
+ try {
117
+ return {
118
+ didKey,
119
+ valid: await verifier.verify(didKey, signingInput, sig),
120
+ };
121
+ } catch (err) {
122
+ throw new SpaceTokenError(
123
+ `could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
124
+ 'BadJwtSignature',
125
+ );
126
+ }
127
+ };
128
+
129
+ const first = await matchesSignature(false);
130
+ if (!first.valid) {
131
+ // A resolver that holds its answers can name a key the caller has rotated
132
+ // away from, and the token is signed by the current one.
133
+ const fresh = await matchesSignature(true);
134
+ if (fresh.didKey === first.didKey || !fresh.valid) {
135
+ throw new SpaceTokenError(
136
+ 'invalid service token signature',
137
+ 'BadJwtSignature',
138
+ );
139
+ }
118
140
  }
119
141
 
120
142
  return { iss: payload.iss };
@@ -131,12 +153,12 @@ export async function verifyServiceAuth({
131
153
  * @param {string} opts.iss - our DID
132
154
  * @param {string} opts.aud - the service we are calling
133
155
  * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
134
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} opts.signer
156
+ * @param {import('./token.js').SpaceSigner} opts.signer
135
157
  * @returns {Promise<string>}
136
158
  */
137
159
  export async function createServiceAuth({ iss, aud, lxm, signer }) {
138
160
  const now = Math.floor(Date.now() / 1000);
139
- const header = { typ: 'JWT', alg: 'ES256' };
161
+ const header = { typ: 'JWT', alg: signerAlg(signer) };
140
162
  const payload = {
141
163
  iss,
142
164
  aud,
package/src/token.d.ts CHANGED
@@ -21,6 +21,21 @@ export declare const SPACE_TOKEN_TYPES: {
21
21
  requireCnf: boolean;
22
22
  };
23
23
  };
24
+ export type SpaceSigner = {
25
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
26
+ /**
27
+ * - p256 when absent
28
+ */
29
+ curve?: 'p256' | 'secp256k1';
30
+ };
31
+ /**
32
+ * The JWT `alg` for a signer's curve. A verifier resolves the issuer's key,
33
+ * reads its type, and refuses a header that names another algorithm before it
34
+ * checks the signature. So a secp256k1 key must stamp ES256K.
35
+ * @param {SpaceSigner} signer
36
+ * @returns {string}
37
+ */
38
+ export declare function signerAlg(signer: SpaceSigner): string;
24
39
  export declare class SpaceTokenError extends Error {
25
40
  code: string;
26
41
  /**
@@ -29,6 +44,18 @@ export declare class SpaceTokenError extends Error {
29
44
  */
30
45
  constructor(message: string, code?: string);
31
46
  }
47
+ /**
48
+ * Refuse a token whose header names an algorithm the issuer's key cannot
49
+ * produce. The signature would verify anyway, since the curve comes from the
50
+ * resolved key rather than the header. The reference implementation rejects
51
+ * the mismatch, so a token this server accepts has to be one the rest of the
52
+ * ecosystem accepts too.
53
+ *
54
+ * @param {string} didKey
55
+ * @param {string} alg - the token header's `alg`
56
+ * @throws {SpaceTokenError}
57
+ */
58
+ export declare function assertKeyAlg(didKey: string, alg: string): void;
32
59
  export type SpaceTokenPayload = {
33
60
  iss: string;
34
61
  /**
@@ -54,7 +81,7 @@ export type SpaceTokenHeader = {
54
81
  /**
55
82
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
56
83
  * @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
57
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
84
+ * @param {SpaceSigner} signer
58
85
  * @returns {Promise<string>}
59
86
  */
60
87
  export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, opts: {
@@ -65,9 +92,7 @@ export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, o
65
92
  expiresInSec?: number;
66
93
  kid?: string;
67
94
  alg?: string;
68
- }, signer: {
69
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
70
- }): Promise<string>;
95
+ }, signer: SpaceSigner): Promise<string>;
71
96
  /**
72
97
  * Structural validation only, no signature check. This is as far as we go for
73
98
  * client attestations, whose key comes from the client's JWKS rather than a DID
@@ -87,15 +112,16 @@ export declare function parseSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jw
87
112
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
88
113
  * @param {string} jwt
89
114
  * @param {Object} opts
90
- * @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
91
- * - resolves the issuer to a did:key. Given `kid` so it can honour the key id.
115
+ * @param {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>} opts.getSigningKey
116
+ * - resolves the issuer to a did:key. Given `kid` so it can honour the key
117
+ * id, and `forceRefresh` to read past a held answer after a signature fails.
92
118
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
93
119
  * @param {string} [opts.aud] - required audience, when the caller knows it
94
120
  * @param {string} [opts.sub] - required subject, when the caller knows it
95
121
  * @returns {Promise<{header: SpaceTokenHeader, payload: SpaceTokenPayload}>}
96
122
  */
97
123
  export declare function verifySpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jwt: string, opts: {
98
- getSigningKey: (iss: string, kid?: string) => Promise<string>;
124
+ getSigningKey: (iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>;
99
125
  verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
100
126
  aud?: string;
101
127
  sub?: string;
package/src/token.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  base64UrlEncode,
13
13
  bytesToHex,
14
14
  } from '@pdsjs/core/crypto';
15
+ import { parseDidKey } from '@pdsjs/core/verify';
15
16
 
16
17
  export const SPACE_TOKEN_TYPES = {
17
18
  delegation: {
@@ -44,6 +45,27 @@ export const SPACE_TOKEN_TYPES = {
44
45
 
45
46
  const CLOCK_SKEW_SEC = 5;
46
47
 
48
+ /**
49
+ * The account key that signs space tokens and repo commits.
50
+ * @typedef {Object} SpaceSigner
51
+ * @property {(bytes: Uint8Array) => Promise<Uint8Array>} sign
52
+ * @property {'p256'|'secp256k1'} [curve] - p256 when absent
53
+ */
54
+
55
+ /** The JWT `alg` each curve signs under. */
56
+ const JWT_ALG = { p256: 'ES256', secp256k1: 'ES256K' };
57
+
58
+ /**
59
+ * The JWT `alg` for a signer's curve. A verifier resolves the issuer's key,
60
+ * reads its type, and refuses a header that names another algorithm before it
61
+ * checks the signature. So a secp256k1 key must stamp ES256K.
62
+ * @param {SpaceSigner} signer
63
+ * @returns {string}
64
+ */
65
+ export function signerAlg(signer) {
66
+ return JWT_ALG[signer.curve ?? 'p256'];
67
+ }
68
+
47
69
  export class SpaceTokenError extends Error {
48
70
  /**
49
71
  * @param {string} message
@@ -56,6 +78,35 @@ export class SpaceTokenError extends Error {
56
78
  }
57
79
  }
58
80
 
81
+ /**
82
+ * Refuse a token whose header names an algorithm the issuer's key cannot
83
+ * produce. The signature would verify anyway, since the curve comes from the
84
+ * resolved key rather than the header. The reference implementation rejects
85
+ * the mismatch, so a token this server accepts has to be one the rest of the
86
+ * ecosystem accepts too.
87
+ *
88
+ * @param {string} didKey
89
+ * @param {string} alg - the token header's `alg`
90
+ * @throws {SpaceTokenError}
91
+ */
92
+ export function assertKeyAlg(didKey, alg) {
93
+ let expected;
94
+ try {
95
+ expected = JWT_ALG[parseDidKey(didKey).curve];
96
+ } catch (err) {
97
+ throw new SpaceTokenError(
98
+ `could not read the issuer key: ${err instanceof Error ? err.message : String(err)}`,
99
+ 'BadJwtSignature',
100
+ );
101
+ }
102
+ if (alg !== expected) {
103
+ throw new SpaceTokenError(
104
+ `token alg ${alg} does not match the issuer key, which signs ${expected}`,
105
+ 'BadJwtSignature',
106
+ );
107
+ }
108
+ }
109
+
59
110
  /**
60
111
  * @typedef {Object} SpaceTokenPayload
61
112
  * @property {string} iss
@@ -101,7 +152,7 @@ function decodeJsonPart(b64, part) {
101
152
  /**
102
153
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
103
154
  * @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
104
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
155
+ * @param {SpaceSigner} signer
105
156
  * @returns {Promise<string>}
106
157
  */
107
158
  export async function createSpaceToken(type, opts, signer) {
@@ -115,7 +166,7 @@ export async function createSpaceToken(type, opts, signer) {
115
166
 
116
167
  const iat = Math.floor(Date.now() / 1000);
117
168
  /** @type {SpaceTokenHeader} */
118
- const header = { alg: opts.alg ?? 'ES256', typ: spec.typ };
169
+ const header = { alg: opts.alg ?? signerAlg(signer), typ: spec.typ };
119
170
  const kid = opts.kid ?? spec.kid;
120
171
  if (kid) header.kid = kid;
121
172
 
@@ -197,8 +248,9 @@ export function parseSpaceToken(type, jwt) {
197
248
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
198
249
  * @param {string} jwt
199
250
  * @param {Object} opts
200
- * @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
201
- * - resolves the issuer to a did:key. Given `kid` so it can honour the key id.
251
+ * @param {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>} opts.getSigningKey
252
+ * - resolves the issuer to a did:key. Given `kid` so it can honour the key
253
+ * id, and `forceRefresh` to read past a held answer after a signature fails.
202
254
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
203
255
  * @param {string} [opts.aud] - required audience, when the caller knows it
204
256
  * @param {string} [opts.sub] - required subject, when the caller knows it
@@ -224,19 +276,31 @@ export async function verifySpaceToken(type, jwt, opts) {
224
276
  );
225
277
  }
226
278
 
279
+ /**
280
+ * @param {string} didKey
281
+ * @returns {Promise<boolean>}
282
+ */
283
+ const matchesSignature = async (didKey) => {
284
+ assertKeyAlg(didKey, header.alg);
285
+ try {
286
+ return await opts.verifier.verify(didKey, signingInput, sig);
287
+ } catch (err) {
288
+ throw new SpaceTokenError(
289
+ `could not verify token signature: ${err instanceof Error ? err.message : String(err)}`,
290
+ 'BadJwtSignature',
291
+ );
292
+ }
293
+ };
294
+
227
295
  const didKey = await opts.getSigningKey(payload.iss, header.kid);
228
- let valid;
229
- try {
230
- valid = await opts.verifier.verify(didKey, signingInput, sig);
231
- } catch (err) {
232
- throw new SpaceTokenError(
233
- `could not verify token signature: ${err instanceof Error ? err.message : String(err)}`,
234
- 'BadJwtSignature',
235
- );
236
- }
237
- if (!valid) {
238
- throw new SpaceTokenError('invalid token signature', 'BadJwtSignature');
296
+ if (await matchesSignature(didKey)) return { header, payload };
297
+
298
+ // A resolver that holds its answers can name a key the issuer has rotated
299
+ // away from, and the token is signed by the current one.
300
+ const freshDidKey = await opts.getSigningKey(payload.iss, header.kid, true);
301
+ if (freshDidKey !== didKey && (await matchesSignature(freshDidKey))) {
302
+ return { header, payload };
239
303
  }
240
304
 
241
- return { header, payload };
305
+ throw new SpaceTokenError('invalid token signature', 'BadJwtSignature');
242
306
  }
package/src/writer.d.ts CHANGED
@@ -23,6 +23,13 @@ export type SpaceWriteInput = {
23
23
  * - required for everything but delete
24
24
  */
25
25
  record?: Object;
26
+ /**
27
+ * - compare-and-swap: the CID the record must
28
+ * currently have, or null to require that it does not exist. Absent means no
29
+ * check. Checked inside the per-space queue, so the comparison and the commit
30
+ * cannot interleave with another write.
31
+ */
32
+ swapCid?: string | null;
26
33
  };
27
34
  export type SpaceWriteResult = {
28
35
  action: 'create' | 'update' | 'delete';
@@ -43,6 +50,10 @@ export type SpaceWriteResult = {
43
50
  * @property {string} collection
44
51
  * @property {string} rkey
45
52
  * @property {Object} [record] - required for everything but delete
53
+ * @property {string|null} [swapCid] - compare-and-swap: the CID the record must
54
+ * currently have, or null to require that it does not exist. Absent means no
55
+ * check. Checked inside the per-space queue, so the comparison and the commit
56
+ * cannot interleave with another write.
46
57
  */
47
58
  /**
48
59
  * @typedef {Object} SpaceWriteResult
package/src/writer.js CHANGED
@@ -88,6 +88,10 @@ function serialize(storage, space, fn) {
88
88
  * @property {string} collection
89
89
  * @property {string} rkey
90
90
  * @property {Object} [record] - required for everything but delete
91
+ * @property {string|null} [swapCid] - compare-and-swap: the CID the record must
92
+ * currently have, or null to require that it does not exist. Absent means no
93
+ * check. Checked inside the per-space queue, so the comparison and the commit
94
+ * cannot interleave with another write.
91
95
  */
92
96
 
93
97
  /**
@@ -150,6 +154,12 @@ export async function applyWrites(storage, { space, writes, now }) {
150
154
  ? /** @type {string|null} */ (staged.get(key))
151
155
  : await storage.getSpaceRecordCid(space, collection, rkey);
152
156
 
157
+ if (write.swapCid !== undefined && write.swapCid !== prev) {
158
+ throw new SpaceWriteError(
159
+ `Record ${collection}/${rkey} is at ${prev ?? 'no version'}, expected ${write.swapCid ?? 'no record'}`,
160
+ 'InvalidSwap',
161
+ );
162
+ }
153
163
  if (write.action === 'create' && prev) {
154
164
  throw new SpaceRecordAlreadyExistsError(collection, rkey);
155
165
  }