@pdsjs/spaces 2.0.1 → 2.0.3

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/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: {
@@ -19,24 +20,52 @@ export const SPACE_TOKEN_TYPES = {
19
20
  kid: '#atproto',
20
21
  expiresInSec: 60,
21
22
  requireAud: true,
23
+ requireCnf: false,
22
24
  },
25
+ // An authority that publishes a dedicated `#atproto_space` key signs with it
26
+ // and names that key in `kid`. This server signs with the account's own
27
+ // `#atproto` key, which is the only key a PDS-hosted authority has.
23
28
  credential: {
24
29
  typ: 'atproto-space-credential+jwt',
25
- kid: '#atproto_space',
26
- // Multi-use across repo hosts until it expires, so it carries no aud.
30
+ kid: '#atproto',
31
+ // Multi-use across repo hosts until it expires, so it carries no aud. It is
32
+ // bound to the holder's key instead — see dpop.js.
27
33
  expiresInSec: 7200,
28
34
  requireAud: false,
35
+ requireCnf: true,
29
36
  },
30
37
  clientAttestation: {
31
38
  typ: 'atproto-client-attestation+jwt',
32
39
  kid: undefined,
33
40
  expiresInSec: 60,
34
41
  requireAud: true,
42
+ requireCnf: false,
35
43
  },
36
44
  };
37
45
 
38
46
  const CLOCK_SKEW_SEC = 5;
39
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
+
40
69
  export class SpaceTokenError extends Error {
41
70
  /**
42
71
  * @param {string} message
@@ -49,6 +78,35 @@ export class SpaceTokenError extends Error {
49
78
  }
50
79
  }
51
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
+
52
110
  /**
53
111
  * @typedef {Object} SpaceTokenPayload
54
112
  * @property {string} iss
@@ -57,6 +115,7 @@ export class SpaceTokenError extends Error {
57
115
  * @property {number} iat
58
116
  * @property {number} exp
59
117
  * @property {string} jti
118
+ * @property {{jkt: string}} [cnf] - the key the holder must prove possession of
60
119
  */
61
120
 
62
121
  /**
@@ -92,8 +151,8 @@ function decodeJsonPart(b64, part) {
92
151
 
93
152
  /**
94
153
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
95
- * @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
96
- * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
154
+ * @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
155
+ * @param {SpaceSigner} signer
97
156
  * @returns {Promise<string>}
98
157
  */
99
158
  export async function createSpaceToken(type, opts, signer) {
@@ -101,10 +160,13 @@ export async function createSpaceToken(type, opts, signer) {
101
160
  if (spec.requireAud && !opts.aud) {
102
161
  throw new SpaceTokenError(`a ${type} token requires an "aud"`);
103
162
  }
163
+ if (spec.requireCnf && !opts.dpopJkt) {
164
+ throw new SpaceTokenError(`a ${type} token requires a "dpopJkt"`);
165
+ }
104
166
 
105
167
  const iat = Math.floor(Date.now() / 1000);
106
168
  /** @type {SpaceTokenHeader} */
107
- const header = { alg: opts.alg ?? 'ES256', typ: spec.typ };
169
+ const header = { alg: opts.alg ?? signerAlg(signer), typ: spec.typ };
108
170
  const kid = opts.kid ?? spec.kid;
109
171
  if (kid) header.kid = kid;
110
172
 
@@ -113,6 +175,7 @@ export async function createSpaceToken(type, opts, signer) {
113
175
  iss: opts.iss,
114
176
  sub: opts.sub,
115
177
  ...(opts.aud ? { aud: opts.aud } : undefined),
178
+ ...(opts.dpopJkt ? { cnf: { jkt: opts.dpopJkt } } : undefined),
116
179
  iat,
117
180
  exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
118
181
  jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
@@ -163,6 +226,9 @@ export function parseSpaceToken(type, jwt) {
163
226
  if (spec.requireAud && !payload.aud) {
164
227
  throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience');
165
228
  }
229
+ if (spec.requireCnf && !payload.cnf?.jkt) {
230
+ throw new SpaceTokenError('missing token "cnf.jkt"', 'BadJwtCnf');
231
+ }
166
232
  if (type === 'clientAttestation' && payload.iss !== payload.sub) {
167
233
  throw new SpaceTokenError(
168
234
  'client attestation "iss" and "sub" must both be the client_id',
@@ -182,8 +248,9 @@ export function parseSpaceToken(type, jwt) {
182
248
  * @param {keyof typeof SPACE_TOKEN_TYPES} type
183
249
  * @param {string} jwt
184
250
  * @param {Object} opts
185
- * @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
186
- * - 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.
187
254
  * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
188
255
  * @param {string} [opts.aud] - required audience, when the caller knows it
189
256
  * @param {string} [opts.sub] - required subject, when the caller knows it
@@ -209,19 +276,31 @@ export async function verifySpaceToken(type, jwt, opts) {
209
276
  );
210
277
  }
211
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
+
212
295
  const didKey = await opts.getSigningKey(payload.iss, header.kid);
213
- let valid;
214
- try {
215
- valid = await opts.verifier.verify(didKey, signingInput, sig);
216
- } catch (err) {
217
- throw new SpaceTokenError(
218
- `could not verify token signature: ${err instanceof Error ? err.message : String(err)}`,
219
- 'BadJwtSignature',
220
- );
221
- }
222
- if (!valid) {
223
- 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 };
224
303
  }
225
304
 
226
- return { header, payload };
305
+ throw new SpaceTokenError('invalid token signature', 'BadJwtSignature');
227
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
  }