@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/package.json +4 -3
- package/src/admin.d.ts +10 -0
- package/src/admin.js +115 -0
- package/src/authority.d.ts +13 -3
- package/src/authority.js +22 -4
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +31 -0
- package/src/dpop.js +121 -0
- package/src/handlers/auth.d.ts +66 -15
- package/src/handlers/auth.js +146 -38
- package/src/handlers/manage.d.ts +4 -6
- package/src/handlers/manage.js +346 -65
- package/src/handlers/read.d.ts +6 -6
- package/src/handlers/read.js +150 -65
- package/src/handlers/write.d.ts +19 -6
- package/src/handlers/write.js +163 -26
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +36 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +14 -6
- package/src/routes.js +15 -2
- package/src/service-auth.d.ts +4 -6
- package/src/service-auth.js +49 -27
- package/src/token.d.ts +44 -8
- package/src/token.js +98 -19
- package/src/writer.d.ts +11 -0
- package/src/writer.js +10 -0
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: '#
|
|
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 {
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
}
|