@pdsjs/spaces 2.0.0 → 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 +2 -2
- package/src/authority.d.ts +10 -0
- package/src/authority.js +18 -0
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +15 -0
- package/src/dpop.js +85 -0
- package/src/handlers/auth.d.ts +56 -1
- package/src/handlers/auth.js +114 -33
- package/src/handlers/manage.js +344 -63
- package/src/handlers/read.d.ts +2 -0
- package/src/handlers/read.js +148 -63
- package/src/handlers/write.d.ts +4 -0
- package/src/handlers/write.js +88 -24
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +38 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +7 -0
- package/src/routes.js +11 -0
- package/src/token.d.ts +11 -1
- package/src/token.js +23 -4
- package/src/verifier.d.ts +1 -15
- package/src/verifier.js +5 -71
package/src/notify.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @pdsjs/spaces/notify - telling a space's syncers that a repo advanced.
|
|
2
|
+
//
|
|
3
|
+
// A space's writes are never broadcast, which is the point of one. So a service
|
|
4
|
+
// that wants to follow a space registers with the authority through
|
|
5
|
+
// registerNotify, and the authority forwards each write notice it learns of. A
|
|
6
|
+
// syncer that receives one holds a session and can read; the notice itself
|
|
7
|
+
// carries no records, only the rev the repo reached.
|
|
8
|
+
|
|
9
|
+
import { createServiceAuth } from './service-auth.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Forward a write notice to every service registered for this space.
|
|
13
|
+
*
|
|
14
|
+
* Best effort, and deliberately so: sync correctness rests on comparing set
|
|
15
|
+
* hashes, so a syncer that misses a notice finds out on the next one or on its
|
|
16
|
+
* own next walk. One unreachable recipient must not fail the others, and none of
|
|
17
|
+
* them may fail the write that produced the notice.
|
|
18
|
+
*
|
|
19
|
+
* The caller awaits this, which puts one round trip per recipient in front of
|
|
20
|
+
* the response. A Worker cancels a promise still running when it answers, and
|
|
21
|
+
* this package holds no execution context to hand the work to, so backgrounding
|
|
22
|
+
* it here would mean dropping notifications on Cloudflare.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} ctx
|
|
25
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
26
|
+
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
27
|
+
* @param {typeof fetch} [ctx.fetch]
|
|
28
|
+
* @param {Object} notice
|
|
29
|
+
* @param {string} notice.authorityDid - the space's authority, which signs
|
|
30
|
+
* @param {string} notice.space
|
|
31
|
+
* @param {string} notice.repo - the account whose repo advanced
|
|
32
|
+
* @param {string} notice.rev
|
|
33
|
+
* @param {Uint8Array} notice.hash - the repo's commit hash after the write
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
export async function forwardToSyncers(
|
|
37
|
+
ctx,
|
|
38
|
+
{ authorityDid, space, repo, rev, hash },
|
|
39
|
+
) {
|
|
40
|
+
let recipients;
|
|
41
|
+
try {
|
|
42
|
+
recipients = await ctx.spaceStorage.listCredentialRecipients(space);
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (recipients.length === 0) return;
|
|
47
|
+
|
|
48
|
+
const body = JSON.stringify({ space, repo, rev, hash: toJsonBytes(hash) });
|
|
49
|
+
const doFetch = ctx.fetch ?? fetch;
|
|
50
|
+
const signer = await ctx.getSigner();
|
|
51
|
+
|
|
52
|
+
await Promise.all(
|
|
53
|
+
recipients.map(async (recipient) => {
|
|
54
|
+
try {
|
|
55
|
+
const token = await createServiceAuth({
|
|
56
|
+
iss: authorityDid,
|
|
57
|
+
aud: recipient.serviceDid,
|
|
58
|
+
lxm: 'com.atproto.space.notifyWrite',
|
|
59
|
+
signer,
|
|
60
|
+
});
|
|
61
|
+
await doFetch(
|
|
62
|
+
`${recipient.serviceEndpoint}/xrpc/com.atproto.space.notifyWrite`,
|
|
63
|
+
{
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'content-type': 'application/json',
|
|
67
|
+
authorization: `Bearer ${token}`,
|
|
68
|
+
},
|
|
69
|
+
body,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
// Best effort.
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Uint8Array} bytes
|
|
81
|
+
* @returns {{$bytes: string}}
|
|
82
|
+
*/
|
|
83
|
+
function toJsonBytes(bytes) {
|
|
84
|
+
let binary = '';
|
|
85
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
86
|
+
return { $bytes: btoa(binary).replace(/=+$/, '') };
|
|
87
|
+
}
|
package/src/routes.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {Object} ctx
|
|
3
3
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
|
|
5
|
+
* from space records live in the account's ordinary blob store
|
|
6
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
7
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
4
8
|
* @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
|
|
5
9
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
6
10
|
* - the account's signing key, used to sign repo commits and space tokens
|
|
@@ -13,6 +17,9 @@
|
|
|
13
17
|
*/
|
|
14
18
|
export declare function createSpaceRoutes(ctx: {
|
|
15
19
|
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
20
|
+
blobs: import('@pdsjs/core/ports').BlobPort;
|
|
21
|
+
linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
|
|
22
|
+
unlinkBlobs: (recordUri: string) => Promise<void>;
|
|
16
23
|
getDid: () => Promise<string | null>;
|
|
17
24
|
getSigner: () => Promise<{
|
|
18
25
|
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
package/src/routes.js
CHANGED
|
@@ -13,6 +13,10 @@ import { createWriteRoutes } from './handlers/write.js';
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {Object} ctx
|
|
15
15
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
16
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
|
|
17
|
+
* from space records live in the account's ordinary blob store
|
|
18
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
19
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
16
20
|
* @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
|
|
17
21
|
* @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
|
|
18
22
|
* - the account's signing key, used to sign repo commits and space tokens
|
|
@@ -33,6 +37,13 @@ export function createSpaceRoutes(ctx) {
|
|
|
33
37
|
if (!ctx.resolveDid || !ctx.verifier) {
|
|
34
38
|
throw new Error('createSpaceRoutes requires resolveDid and a verifier');
|
|
35
39
|
}
|
|
40
|
+
// Refused rather than defaulted: without the link callbacks a space record's
|
|
41
|
+
// blobs look unreferenced, and orphan cleanup deletes them.
|
|
42
|
+
if (!ctx.blobs || !ctx.linkBlob || !ctx.unlinkBlobs) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'createSpaceRoutes requires blobs, linkBlob and unlinkBlobs',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
36
47
|
return {
|
|
37
48
|
...createWriteRoutes(ctx),
|
|
38
49
|
...createReadRoutes(ctx),
|
package/src/token.d.ts
CHANGED
|
@@ -4,18 +4,21 @@ export declare const SPACE_TOKEN_TYPES: {
|
|
|
4
4
|
kid: string;
|
|
5
5
|
expiresInSec: number;
|
|
6
6
|
requireAud: boolean;
|
|
7
|
+
requireCnf: boolean;
|
|
7
8
|
};
|
|
8
9
|
credential: {
|
|
9
10
|
typ: string;
|
|
10
11
|
kid: string;
|
|
11
12
|
expiresInSec: number;
|
|
12
13
|
requireAud: boolean;
|
|
14
|
+
requireCnf: boolean;
|
|
13
15
|
};
|
|
14
16
|
clientAttestation: {
|
|
15
17
|
typ: string;
|
|
16
18
|
kid: undefined;
|
|
17
19
|
expiresInSec: number;
|
|
18
20
|
requireAud: boolean;
|
|
21
|
+
requireCnf: boolean;
|
|
19
22
|
};
|
|
20
23
|
};
|
|
21
24
|
export declare class SpaceTokenError extends Error {
|
|
@@ -36,6 +39,12 @@ export type SpaceTokenPayload = {
|
|
|
36
39
|
iat: number;
|
|
37
40
|
exp: number;
|
|
38
41
|
jti: string;
|
|
42
|
+
/**
|
|
43
|
+
* - the key the holder must prove possession of
|
|
44
|
+
*/
|
|
45
|
+
cnf?: {
|
|
46
|
+
jkt: string;
|
|
47
|
+
};
|
|
39
48
|
};
|
|
40
49
|
export type SpaceTokenHeader = {
|
|
41
50
|
alg: string;
|
|
@@ -44,7 +53,7 @@ export type SpaceTokenHeader = {
|
|
|
44
53
|
};
|
|
45
54
|
/**
|
|
46
55
|
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
47
|
-
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
56
|
+
* @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
48
57
|
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
|
|
49
58
|
* @returns {Promise<string>}
|
|
50
59
|
*/
|
|
@@ -52,6 +61,7 @@ export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, o
|
|
|
52
61
|
iss: string;
|
|
53
62
|
sub: string;
|
|
54
63
|
aud?: string;
|
|
64
|
+
dpopJkt?: string;
|
|
55
65
|
expiresInSec?: number;
|
|
56
66
|
kid?: string;
|
|
57
67
|
alg?: string;
|
package/src/token.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// They share a wire shape and differ only in who signs, who they address, and
|
|
8
8
|
// how long they live — so they are data, not three implementations.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
base64UrlDecode,
|
|
12
|
+
base64UrlEncode,
|
|
13
|
+
bytesToHex,
|
|
14
|
+
} from '@pdsjs/core/crypto';
|
|
11
15
|
|
|
12
16
|
export const SPACE_TOKEN_TYPES = {
|
|
13
17
|
delegation: {
|
|
@@ -15,19 +19,26 @@ export const SPACE_TOKEN_TYPES = {
|
|
|
15
19
|
kid: '#atproto',
|
|
16
20
|
expiresInSec: 60,
|
|
17
21
|
requireAud: true,
|
|
22
|
+
requireCnf: false,
|
|
18
23
|
},
|
|
24
|
+
// An authority that publishes a dedicated `#atproto_space` key signs with it
|
|
25
|
+
// and names that key in `kid`. This server signs with the account's own
|
|
26
|
+
// `#atproto` key, which is the only key a PDS-hosted authority has.
|
|
19
27
|
credential: {
|
|
20
28
|
typ: 'atproto-space-credential+jwt',
|
|
21
|
-
kid: '#
|
|
22
|
-
// Multi-use across repo hosts until it expires, so it carries no aud.
|
|
29
|
+
kid: '#atproto',
|
|
30
|
+
// Multi-use across repo hosts until it expires, so it carries no aud. It is
|
|
31
|
+
// bound to the holder's key instead — see dpop.js.
|
|
23
32
|
expiresInSec: 7200,
|
|
24
33
|
requireAud: false,
|
|
34
|
+
requireCnf: true,
|
|
25
35
|
},
|
|
26
36
|
clientAttestation: {
|
|
27
37
|
typ: 'atproto-client-attestation+jwt',
|
|
28
38
|
kid: undefined,
|
|
29
39
|
expiresInSec: 60,
|
|
30
40
|
requireAud: true,
|
|
41
|
+
requireCnf: false,
|
|
31
42
|
},
|
|
32
43
|
};
|
|
33
44
|
|
|
@@ -53,6 +64,7 @@ export class SpaceTokenError extends Error {
|
|
|
53
64
|
* @property {number} iat
|
|
54
65
|
* @property {number} exp
|
|
55
66
|
* @property {string} jti
|
|
67
|
+
* @property {{jkt: string}} [cnf] - the key the holder must prove possession of
|
|
56
68
|
*/
|
|
57
69
|
|
|
58
70
|
/**
|
|
@@ -88,7 +100,7 @@ function decodeJsonPart(b64, part) {
|
|
|
88
100
|
|
|
89
101
|
/**
|
|
90
102
|
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
91
|
-
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
103
|
+
* @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
92
104
|
* @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
|
|
93
105
|
* @returns {Promise<string>}
|
|
94
106
|
*/
|
|
@@ -97,6 +109,9 @@ export async function createSpaceToken(type, opts, signer) {
|
|
|
97
109
|
if (spec.requireAud && !opts.aud) {
|
|
98
110
|
throw new SpaceTokenError(`a ${type} token requires an "aud"`);
|
|
99
111
|
}
|
|
112
|
+
if (spec.requireCnf && !opts.dpopJkt) {
|
|
113
|
+
throw new SpaceTokenError(`a ${type} token requires a "dpopJkt"`);
|
|
114
|
+
}
|
|
100
115
|
|
|
101
116
|
const iat = Math.floor(Date.now() / 1000);
|
|
102
117
|
/** @type {SpaceTokenHeader} */
|
|
@@ -109,6 +124,7 @@ export async function createSpaceToken(type, opts, signer) {
|
|
|
109
124
|
iss: opts.iss,
|
|
110
125
|
sub: opts.sub,
|
|
111
126
|
...(opts.aud ? { aud: opts.aud } : undefined),
|
|
127
|
+
...(opts.dpopJkt ? { cnf: { jkt: opts.dpopJkt } } : undefined),
|
|
112
128
|
iat,
|
|
113
129
|
exp: iat + (opts.expiresInSec ?? spec.expiresInSec),
|
|
114
130
|
jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
|
|
@@ -159,6 +175,9 @@ export function parseSpaceToken(type, jwt) {
|
|
|
159
175
|
if (spec.requireAud && !payload.aud) {
|
|
160
176
|
throw new SpaceTokenError('missing token "aud"', 'BadJwtAudience');
|
|
161
177
|
}
|
|
178
|
+
if (spec.requireCnf && !payload.cnf?.jkt) {
|
|
179
|
+
throw new SpaceTokenError('missing token "cnf.jkt"', 'BadJwtCnf');
|
|
180
|
+
}
|
|
162
181
|
if (type === 'clientAttestation' && payload.iss !== payload.sub) {
|
|
163
182
|
throw new SpaceTokenError(
|
|
164
183
|
'client attestation "iss" and "sub" must both be the client_id',
|
package/src/verifier.d.ts
CHANGED
|
@@ -1,15 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* @param {string} didKey - did:key:z… (the did: prefix is optional)
|
|
3
|
-
* @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
|
|
4
|
-
*/
|
|
5
|
-
export declare function parseDidKey(didKey: string): {
|
|
6
|
-
curve: 'p256' | 'secp256k1';
|
|
7
|
-
publicKey: Uint8Array;
|
|
8
|
-
};
|
|
9
|
-
/**
|
|
10
|
-
* @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
|
|
11
|
-
* @returns {import('@pdsjs/core/ports').SignatureVerifierPort}
|
|
12
|
-
*/
|
|
13
|
-
export declare function createVerifier(opts?: {
|
|
14
|
-
secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>;
|
|
15
|
-
}): import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
1
|
+
export { createVerifier, parseDidKey } from '@pdsjs/core/verify';
|
package/src/verifier.js
CHANGED
|
@@ -1,74 +1,8 @@
|
|
|
1
1
|
// @pdsjs/spaces/verifier - signature verification port.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The implementation now lives in @pdsjs/core/verify so that core features
|
|
4
|
+
// (service-auth verification on account migration) and spaces (space
|
|
5
|
+
// credentials) share one verifier. This module re-exports it to keep the
|
|
6
|
+
// @pdsjs/spaces/verifier entry point stable for existing importers.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const SECP256K1_MULTICODEC = [0xe7, 0x01];
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @param {string} didKey - did:key:z… (the did: prefix is optional)
|
|
14
|
-
* @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
|
|
15
|
-
*/
|
|
16
|
-
export function parseDidKey(didKey) {
|
|
17
|
-
const value = didKey.startsWith('did:key:') ? didKey.slice(8) : didKey;
|
|
18
|
-
if (!value.startsWith('z')) {
|
|
19
|
-
throw new Error(`expected a did:key multibase value, got: ${didKey}`);
|
|
20
|
-
}
|
|
21
|
-
const bytes = base58btcDecode(value.slice(1));
|
|
22
|
-
if (bytes[0] === P256_MULTICODEC[0] && bytes[1] === P256_MULTICODEC[1]) {
|
|
23
|
-
return { curve: 'p256', publicKey: bytes.slice(2) };
|
|
24
|
-
}
|
|
25
|
-
if (
|
|
26
|
-
bytes[0] === SECP256K1_MULTICODEC[0] &&
|
|
27
|
-
bytes[1] === SECP256K1_MULTICODEC[1]
|
|
28
|
-
) {
|
|
29
|
-
return { curve: 'secp256k1', publicKey: bytes.slice(2) };
|
|
30
|
-
}
|
|
31
|
-
throw new Error(`unsupported did:key multicodec: ${didKey}`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
|
|
36
|
-
* @returns {import('@pdsjs/core/ports').SignatureVerifierPort}
|
|
37
|
-
*/
|
|
38
|
-
export function createVerifier(opts = {}) {
|
|
39
|
-
return {
|
|
40
|
-
async verify(didKey, data, sig) {
|
|
41
|
-
const { curve, publicKey } = parseDidKey(didKey);
|
|
42
|
-
if (curve === 'p256') return verifyP256(publicKey, data, sig);
|
|
43
|
-
if (!opts.secp256k1) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
'secp256k1 verification requires an injected verifier; ' +
|
|
46
|
-
'this deployment supports P-256 keys only',
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
return opts.secp256k1(publicKey, data, sig);
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @param {Uint8Array} publicKey - 33-byte compressed point
|
|
56
|
-
* @param {Uint8Array} data
|
|
57
|
-
* @param {Uint8Array} sig - 64-byte raw r||s
|
|
58
|
-
* @returns {Promise<boolean>}
|
|
59
|
-
*/
|
|
60
|
-
async function verifyP256(publicKey, data, sig) {
|
|
61
|
-
const key = await crypto.subtle.importKey(
|
|
62
|
-
'raw',
|
|
63
|
-
/** @type {BufferSource} */ (publicKey),
|
|
64
|
-
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
65
|
-
false,
|
|
66
|
-
['verify'],
|
|
67
|
-
);
|
|
68
|
-
return crypto.subtle.verify(
|
|
69
|
-
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
70
|
-
key,
|
|
71
|
-
/** @type {BufferSource} */ (sig),
|
|
72
|
-
/** @type {BufferSource} */ (data),
|
|
73
|
-
);
|
|
74
|
-
}
|
|
8
|
+
export { createVerifier, parseDidKey } from '@pdsjs/core/verify';
|