@hatk/hatk 0.0.1-alpha.100 → 0.0.1-alpha.101

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/dist/server.js CHANGED
@@ -1252,7 +1252,7 @@ export function createHandler(config) {
1252
1252
  return withCors(jsonError(404, 'This instance receives no space notices', acceptEncoding));
1253
1253
  const notice = parseWriteNotice(await request.json().catch(() => null));
1254
1254
  if (!notice)
1255
- return withCors(jsonError(400, 'Expected space, repo and rev', acceptEncoding));
1255
+ return withCors(jsonError(400, 'Expected space, repo and repoRev', acceptEncoding));
1256
1256
  try {
1257
1257
  await verifyNotice(request.headers.get('authorization'), {
1258
1258
  iss: parseSpaceRef(notice.space).authority,
@@ -34,7 +34,7 @@ const SERVABLE = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif',
34
34
  * a viewer who has just lost access still sees what they had. A minute is
35
35
  * small against what the surrounding design already accepts: the readable set
36
36
  * is cached for five, and the space host lets a minted credential outlive a
37
- * revocation by up to two hours.
37
+ * revocation by up to ten minutes unless the authority revokes it early.
38
38
  */
39
39
  const MAX_AGE_S = 60;
40
40
  /**
@@ -13,12 +13,13 @@
13
13
  * the reader's PDS signs a delegation token (60s, single-use, addressed to
14
14
  * the authority's space host)
15
15
  * the authority checks it, checks the reader against the space's policy, and
16
- * returns a credential bound to the key that signed our DPoP proof (2h)
16
+ * returns a credential bound to the key that signed the exchange (10 min)
17
17
  *
18
18
  * — and the credential is then presented to every writer's host in the space.
19
19
  * It carries no audience, which is why it must be bound to a key rather than
20
- * held as a bearer token: a host handed a bearer credential to serve its own
21
- * repo could replay it against every other host in the space.
20
+ * held as a bearer token: each request signs the credential together with the
21
+ * DID it is addressed to, so a host handed one to serve its own repo cannot
22
+ * replay it against any other account. See signature.ts.
22
23
  *
23
24
  * The consequence to be honest about: a space is only readable while somebody
24
25
  * who can read it has a live session here. That is the same bound the data has
@@ -40,7 +41,10 @@ export interface SpaceCredential {
40
41
  /** The account whose delegation bought it. */
41
42
  readerDid: string;
42
43
  expiresAt: number;
43
- /** A fetch that presents the credential with a fresh DPoP proof per request. */
44
+ /**
45
+ * A fetch that presents the credential, signed for whoever the request is
46
+ * addressed to: the `repo` it names, or else the space's authority.
47
+ */
44
48
  fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
45
49
  }
46
50
  /** Mint a credential for `space` using `readerDid`'s stored session. */
@@ -57,6 +61,6 @@ export declare function getSpaceCredential(oauthConfig: OAuthConfig, space: stri
57
61
  refresh?: boolean;
58
62
  }): Promise<SpaceCredential>;
59
63
  export declare function forgetSpaceCredential(space: string): void;
60
- /** For tests: drop every cached credential and the process binding key. */
64
+ /** For tests: drop every cached credential. */
61
65
  export declare function resetSpaceCredentials(): void;
62
66
  //# sourceMappingURL=credential.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"credential.d.ts","sourceRoot":"","sources":["../../src/spaces/credential.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAsB/C,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAIlB;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAErD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEjD;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAA;IACb,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtE;AAiDD,wEAAwE;AACxE,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAiC1B;AAUD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAAE,EACpB,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,CAAC,CAwB1B;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,2EAA2E;AAC3E,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C"}
1
+ {"version":3,"file":"credential.d.ts","sourceRoot":"","sources":["../../src/spaces/credential.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAa/C,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;gBADZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM;CAIlB;AAED,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAErD;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEjD;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAA;IACb,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtE;AA4CD,wEAAwE;AACxE,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,eAAe,CAAC,CAwC1B;AAUD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAAE,EACpB,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAO,GAC/B,OAAO,CAAC,eAAe,CAAC,CAwB1B;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,+CAA+C;AAC/C,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C"}
@@ -13,36 +13,29 @@
13
13
  * the reader's PDS signs a delegation token (60s, single-use, addressed to
14
14
  * the authority's space host)
15
15
  * the authority checks it, checks the reader against the space's policy, and
16
- * returns a credential bound to the key that signed our DPoP proof (2h)
16
+ * returns a credential bound to the key that signed the exchange (10 min)
17
17
  *
18
18
  * — and the credential is then presented to every writer's host in the space.
19
19
  * It carries no audience, which is why it must be bound to a key rather than
20
- * held as a bearer token: a host handed a bearer credential to serve its own
21
- * repo could replay it against every other host in the space.
20
+ * held as a bearer token: each request signs the credential together with the
21
+ * DID it is addressed to, so a host handed one to serve its own repo cannot
22
+ * replay it against any other account. See signature.ts.
22
23
  *
23
24
  * The consequence to be honest about: a space is only readable while somebody
24
25
  * who can read it has a live session here. That is the same bound the data has
25
26
  * anyway — nobody outside the space's audience was ever entitled to this — but
26
27
  * it does mean indexing lapses when the last member logs out.
27
28
  */
28
- import { computeJwkThumbprint, generateKeyPair } from "../oauth/crypto.js";
29
- import { createDpopProof } from "../oauth/dpop.js";
30
29
  import { pdsXrpc } from "../pds-proxy.js";
31
30
  import { emit } from "../logger.js";
32
31
  import { spaceHostEndpoint } from "./identity.js";
32
+ import { generateSpaceSigKey, spaceSigHeaders } from "./signature.js";
33
33
  import { parseSpaceRef } from "./uri.js";
34
- /** Re-mint this long before the credential's own expiry rather than racing it. */
35
- const RENEW_LEAD_MS = 5 * 60 * 1000;
36
34
  /**
37
- * The key credentials are bound to, generated once per process and never
38
- * persisted. It is registered nowhere, so a restart costs one round trip to
39
- * mint fresh credentials and nothing else.
35
+ * Re-mint this long before the credential's own expiry rather than racing it.
36
+ * Credentials live ten minutes, so this keeps nine of them.
40
37
  */
41
- let bindingKey = null;
42
- function getBindingKey() {
43
- bindingKey ??= generateKeyPair();
44
- return bindingKey;
45
- }
38
+ const RENEW_LEAD_MS = 60 * 1000;
46
39
  export class SpaceCredentialError extends Error {
47
40
  status;
48
41
  code;
@@ -71,28 +64,19 @@ function expiryOf(jwt) {
71
64
  }
72
65
  }
73
66
  /**
74
- * Exchange a delegation token for a credential at the authority.
75
- *
76
- * The proof sent here carries no `ath`: a delegation token is an authorization
77
- * grant rather than an access token, so there is nothing to hash yet, and a
78
- * server that checks will refuse a proof that includes one. `dpopJkt` is sent
79
- * alongside because implementations differ on which they read — the reference
80
- * reads the proof, others read the field — and they agree here by construction.
67
+ * Exchange a delegation token for a credential at the authority, binding it to
68
+ * `key` by signing the token with it.
81
69
  */
82
- async function exchange(authorityHost, space, delegationToken) {
83
- const { privateJwk, publicJwk } = await getBindingKey();
70
+ async function exchange(authorityHost, space, delegationToken, key) {
84
71
  const url = `${authorityHost}/xrpc/com.atproto.space.getSpaceCredential`;
85
- const proof = await createDpopProof(privateJwk, publicJwk, 'POST', url);
86
- const dpopJkt = await computeJwkThumbprint(publicJwk);
87
72
  const res = await fetch(url, {
88
73
  method: 'POST',
89
74
  headers: {
90
75
  'Content-Type': 'application/json',
91
76
  Accept: 'application/json',
92
- Authorization: `Bearer ${delegationToken}`,
93
- DPoP: proof,
77
+ ...(await spaceSigHeaders(key, `Bearer ${delegationToken}`)),
94
78
  },
95
- body: JSON.stringify({ space, dpopJkt }),
79
+ body: JSON.stringify({ space }),
96
80
  });
97
81
  const body = (await res.json().catch(() => ({})));
98
82
  if (!res.ok || !body.credential) {
@@ -120,17 +104,25 @@ export async function mintSpaceCredential(oauthConfig, space, readerDid) {
120
104
  catch (err) {
121
105
  throw new SpaceCredentialError(err?.status ?? 502, 'DelegationFailed', err?.message ?? 'delegation failed');
122
106
  }
107
+ // A key per credential, held only as long as the credential is.
108
+ const key = await generateSpaceSigKey();
123
109
  const authorityHost = await spaceHostEndpoint(ref.authority);
124
- const credential = await exchange(authorityHost, space, token);
125
- const { privateJwk, publicJwk } = await getBindingKey();
110
+ const credential = await exchange(authorityHost, space, token, key);
111
+ // Signatures depend only on the credential and the audience, so each is made
112
+ // once and reused for as long as the credential lives.
113
+ const signed = new Map();
126
114
  const credentialFetch = async (input, init) => {
127
115
  const url = typeof input === 'string' ? input : input.toString();
128
- const method = (init?.method ?? 'GET').toUpperCase();
129
- const proof = await createDpopProof(privateJwk, publicJwk, method, url, credential);
116
+ const audience = new URL(url).searchParams.get('repo') ?? ref.authority;
117
+ let sig = signed.get(audience);
118
+ if (!sig) {
119
+ sig = spaceSigHeaders(key, `Atproto-Space ${credential}`, audience);
120
+ signed.set(audience, sig);
121
+ }
130
122
  const headers = new Headers(init?.headers);
131
- headers.set('Authorization', `DPoP ${credential}`);
132
- headers.set('DPoP', proof);
133
- return fetch(url, { ...init, method, headers });
123
+ for (const [name, value] of Object.entries(await sig))
124
+ headers.set(name, value);
125
+ return fetch(url, { ...init, headers });
134
126
  };
135
127
  return { space, readerDid, expiresAt: expiryOf(credential), fetch: credentialFetch };
136
128
  }
@@ -177,8 +169,7 @@ export async function getSpaceCredential(oauthConfig, space, candidates, opts =
177
169
  export function forgetSpaceCredential(space) {
178
170
  cache.delete(space);
179
171
  }
180
- /** For tests: drop every cached credential and the process binding key. */
172
+ /** For tests: drop every cached credential. */
181
173
  export function resetSpaceCredentials() {
182
174
  cache.clear();
183
- bindingKey = null;
184
175
  }
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/spaces/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAQ/C,OAAO,EAA0D,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAE9G,OAAO,EAUL,KAAK,UAAU,EAChB,MAAM,YAAY,CAAA;AAMnB,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,WAAW,CAAA;IAClB,4EAA4E;IAC5E,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAClB,mFAAmF;IACnF,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAaD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,CAEnE;AAOD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAQnE;AAoUD,iFAAiF;AACjF,wBAAsB,aAAa,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBjH;AAkED;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCrE;AAID,oDAAoD;AACpD,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAUnE;AAED,kEAAkE;AAClE,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/D;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAKjG;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAclD"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/spaces/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAQ/C,OAAO,EAA0D,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAE9G,OAAO,EAUL,KAAK,UAAU,EAChB,MAAM,YAAY,CAAA;AAMnB,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,WAAW,CAAA;IAClB,4EAA4E;IAC5E,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAClB,mFAAmF;IACnF,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAaD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,CAEnE;AAOD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAQnE;AAqUD,iFAAiF;AACjF,wBAAsB,aAAa,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBjH;AAqED;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCrE;AAID,oDAAoD;AACpD,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAUnE;AAED,kEAAkE;AAClE,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/D;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAKjG;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAclD"}
@@ -128,9 +128,10 @@ function codeOf(err) {
128
128
  /**
129
129
  * Run a credentialed read, re-minting once on a 401.
130
130
  *
131
- * A credential lives two hours and a sweep can outlive one. Re-minting costs a
132
- * round trip; treating the expiry as a failure costs the whole sync and leaves
133
- * the space looking unreadable when nothing about the membership changed.
131
+ * A credential lives ten minutes and a sweep can outlive one; an authority can
132
+ * also revoke one early. Re-minting costs a round trip; treating the refusal as
133
+ * a failure costs the whole sync and leaves the space looking unreadable when
134
+ * nothing about the membership changed.
134
135
  */
135
136
  async function withCredential(watch, operation) {
136
137
  let credential = await credentialFor(watch);
@@ -430,14 +431,17 @@ async function listWriters(watch, credential) {
430
431
  cursor,
431
432
  limit: 1000,
432
433
  });
434
+ // Ordered by space revision, and a repo updated mid-walk can turn up twice;
435
+ // the later entry is the newer state. The walk ends on an empty page, the
436
+ // only one without a cursor.
433
437
  for (const repo of (out.repos ?? [])) {
434
- writers.push({ did: repo.did, rev: typeof repo.rev === 'string' ? repo.rev : null });
438
+ writers.push({ did: repo.did, rev: typeof repo.repoRev === 'string' ? repo.repoRev : null });
435
439
  }
436
440
  cursor = typeof out.cursor === 'string' ? out.cursor : undefined;
437
441
  if (!cursor)
438
442
  break;
439
443
  }
440
- return writers;
444
+ return [...new Map(writers.map((w) => [w.did, w])).values()];
441
445
  }
442
446
  /**
443
447
  * Compare the authority's writer set against what the index holds, and close
@@ -42,8 +42,17 @@ export declare function verifyNotice(authorization: string | null, expected: {
42
42
  export interface WriteNotice {
43
43
  space: string;
44
44
  repo: string;
45
- rev: string;
45
+ /** The revision the repo reached. */
46
+ repoRev: string;
47
+ /** Where the authority sequenced the write across the whole space. */
48
+ spaceRev?: string;
46
49
  }
50
+ /**
51
+ * A forwarded notice is `{ space, repo, repoRev, hash, spaceRev, prevSpaceRev? }`.
52
+ * An authority on a release before the space-revision split sends `rev` in
53
+ * place of `repoRev` and no `spaceRev`; that is read too, since nothing here
54
+ * depends on either beyond knowing which repo to look at.
55
+ */
47
56
  export declare function parseWriteNotice(body: unknown): WriteNotice | null;
48
57
  export declare function scheduleNoticeSync(key: string, run: () => Promise<void>, delay?: number): void;
49
58
  /** Drop every pending sync. For shutdown, and for tests. */
@@ -1 +1 @@
1
- {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/spaces/notify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAUH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,MAAM,EAAE,MAAM;gBAAd,MAAM,EAAE,MAAM,EACvB,OAAO,EAAE,MAAM;CAIlB;AAcD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,QAAQ,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAiCf;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAMlE;AAcD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,SAAc,GAAG,IAAI,CASnG;AAED,4DAA4D;AAC5D,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C"}
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/spaces/notify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAUH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,MAAM,EAAE,MAAM;gBAAd,MAAM,EAAE,MAAM,EACvB,OAAO,EAAE,MAAM;CAIlB;AAcD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,QAAQ,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAiCf;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,WAAW,GAAG,IAAI,CAOlE;AAcD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,SAAc,GAAG,IAAI,CASnG;AAED,4DAA4D;AAC5D,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C"}
@@ -82,15 +82,22 @@ export async function verifyNotice(authorization, expected) {
82
82
  throw new NoticeError(403, 'Notice signature does not verify');
83
83
  }
84
84
  }
85
+ /**
86
+ * A forwarded notice is `{ space, repo, repoRev, hash, spaceRev, prevSpaceRev? }`.
87
+ * An authority on a release before the space-revision split sends `rev` in
88
+ * place of `repoRev` and no `spaceRev`; that is read too, since nothing here
89
+ * depends on either beyond knowing which repo to look at.
90
+ */
85
91
  export function parseWriteNotice(body) {
86
92
  if (!body || typeof body !== 'object')
87
93
  return null;
88
- const { space, repo, rev } = body;
89
- if (typeof space !== 'string' || typeof repo !== 'string' || typeof rev !== 'string')
94
+ const { space, repo, repoRev, rev, spaceRev } = body;
95
+ const at = typeof repoRev === 'string' ? repoRev : rev;
96
+ if (typeof space !== 'string' || typeof repo !== 'string' || typeof at !== 'string')
90
97
  return null;
91
98
  if (!parseSpaceRef(space) || !repo.startsWith('did:'))
92
99
  return null;
93
- return { space, repo, rev };
100
+ return { space, repo, repoRev: at, ...(typeof spaceRev === 'string' ? { spaceRev } : {}) };
94
101
  }
95
102
  /**
96
103
  * Collapse a burst of notices for the same repo into one sync.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The HTTP message signature a space credential travels with.
3
+ *
4
+ * RFC 9421, under the label `atproto-space`, signed ecdsa-p256-sha256 by a key
5
+ * made for one credential and named by its did:key. Two shapes:
6
+ *
7
+ * exchange the delegation token is signed alone, and the signature names
8
+ * its key — that key is what the authority binds the credential
9
+ * to (`cnf.kid`)
10
+ * use the credential is signed together with the DID the request is
11
+ * addressed to, so a credential lifted off one request cannot be
12
+ * replayed at another account by anyone without the key
13
+ *
14
+ * The signature is the raw 64-byte r || s, normalized to low-S: the reference
15
+ * verifier accepts either, but a stricter one (noble's default) only low-S.
16
+ */
17
+ /** Encode base58btc. The inverse of `decodeBase58btc` in verify.ts. */
18
+ export declare function encodeBase58btc(bytes: Uint8Array): string;
19
+ export interface SpaceSigKey {
20
+ /** The public key as a P-256 did:key. */
21
+ did: string;
22
+ sign: (data: Uint8Array) => Promise<Uint8Array>;
23
+ }
24
+ /** A fresh P-256 key, held in memory only and never exported. */
25
+ export declare function generateSpaceSigKey(): Promise<SpaceSigKey>;
26
+ /**
27
+ * The bytes a signature covers: one line per covered field, then the
28
+ * signature parameters, joined by LF with no trailing newline.
29
+ */
30
+ export declare function signatureBase(authorization: string, params: string, audience?: string): Uint8Array;
31
+ /**
32
+ * Authorization, audience and signature headers for a space request. Without
33
+ * an audience this is the exchange; with one, a use of the credential.
34
+ */
35
+ export declare function spaceSigHeaders(key: SpaceSigKey, authorization: string, audience?: string): Promise<Record<string, string>>;
36
+ //# sourceMappingURL=signature.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signature.d.ts","sourceRoot":"","sources":["../../src/spaces/signature.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH,uEAAuE;AACvE,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAqBzD;AAED,MAAM,WAAW,WAAW;IAC1B,yCAAyC;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,CAAC,UAAU,CAAC,CAAA;CAChD;AAED,iEAAiE;AACjE,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,WAAW,CAAC,CAYhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAKlG;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,WAAW,EAChB,aAAa,EAAE,MAAM,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAUjC"}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The HTTP message signature a space credential travels with.
3
+ *
4
+ * RFC 9421, under the label `atproto-space`, signed ecdsa-p256-sha256 by a key
5
+ * made for one credential and named by its did:key. Two shapes:
6
+ *
7
+ * exchange the delegation token is signed alone, and the signature names
8
+ * its key — that key is what the authority binds the credential
9
+ * to (`cnf.kid`)
10
+ * use the credential is signed together with the DID the request is
11
+ * addressed to, so a credential lifted off one request cannot be
12
+ * replayed at another account by anyone without the key
13
+ *
14
+ * The signature is the raw 64-byte r || s, normalized to low-S: the reference
15
+ * verifier accepts either, but a stricter one (noble's default) only low-S.
16
+ */
17
+ import { signEs256 } from "../oauth/crypto.js";
18
+ const LABEL = 'atproto-space';
19
+ const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
20
+ /** Encode base58btc. The inverse of `decodeBase58btc` in verify.ts. */
21
+ export function encodeBase58btc(bytes) {
22
+ const digits = [0];
23
+ for (const byte of bytes) {
24
+ let carry = byte;
25
+ for (let i = 0; i < digits.length; i++) {
26
+ carry += digits[i] << 8;
27
+ digits[i] = carry % 58;
28
+ carry = (carry / 58) | 0;
29
+ }
30
+ while (carry) {
31
+ digits.push(carry % 58);
32
+ carry = (carry / 58) | 0;
33
+ }
34
+ }
35
+ let out = '';
36
+ for (const byte of bytes) {
37
+ if (byte !== 0)
38
+ break;
39
+ out += '1';
40
+ }
41
+ for (let i = digits.length - 1; i >= 0; i--)
42
+ out += B58[digits[i]];
43
+ return out;
44
+ }
45
+ /** A fresh P-256 key, held in memory only and never exported. */
46
+ export async function generateSpaceSigKey() {
47
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
48
+ // 0x04 || x || y. A did:key carries the compressed point behind the
49
+ // p256-pub multicodec prefix (0x1200, varint 0x80 0x24).
50
+ const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
51
+ const compressed = new Uint8Array(33);
52
+ compressed[0] = raw[64] & 1 ? 0x03 : 0x02;
53
+ compressed.set(raw.subarray(1, 33), 1);
54
+ return {
55
+ did: `did:key:z${encodeBase58btc(new Uint8Array([0x80, 0x24, ...compressed]))}`,
56
+ sign: (data) => signEs256(pair.privateKey, data),
57
+ };
58
+ }
59
+ /**
60
+ * The bytes a signature covers: one line per covered field, then the
61
+ * signature parameters, joined by LF with no trailing newline.
62
+ */
63
+ export function signatureBase(authorization, params, audience) {
64
+ const lines = [`"authorization": ${authorization}`];
65
+ if (audience !== undefined)
66
+ lines.push(`"atproto-space-audience": ${audience}`);
67
+ lines.push(`"@signature-params": ${params}`);
68
+ return new TextEncoder().encode(lines.join('\n'));
69
+ }
70
+ /**
71
+ * Authorization, audience and signature headers for a space request. Without
72
+ * an audience this is the exchange; with one, a use of the credential.
73
+ */
74
+ export async function spaceSigHeaders(key, authorization, audience) {
75
+ const params = audience === undefined ? `("authorization");keyid="${key.did}"` : '("authorization" "atproto-space-audience")';
76
+ const signature = await key.sign(signatureBase(authorization, params, audience));
77
+ return {
78
+ Authorization: authorization,
79
+ ...(audience !== undefined ? { 'Atproto-Space-Audience': audience } : {}),
80
+ 'Signature-Input': `${LABEL}=${params}`,
81
+ Signature: `${LABEL}=:${Buffer.from(signature).toString('base64')}:`,
82
+ };
83
+ }
@@ -20,7 +20,7 @@
20
20
  * somebody ejected from a community may still be served that community's rows
21
21
  * for a few minutes. The alternative is two round trips on every request, and
22
22
  * the space host itself already lets a minted credential outlive a revocation
23
- * by up to two hours.
23
+ * by up to ten minutes.
24
24
  */
25
25
  import type { OAuthConfig } from '../config.ts';
26
26
  import { type SpaceCredential } from './credential.ts';
@@ -20,7 +20,7 @@
20
20
  * somebody ejected from a community may still be served that community's rows
21
21
  * for a few minutes. The alternative is two round trips on every request, and
22
22
  * the space host itself already lets a minted credential outlive a revocation
23
- * by up to two hours.
23
+ * by up to ten minutes.
24
24
  */
25
25
  import { emit } from "../logger.js";
26
26
  import { mintSpaceCredential, isSpaceGone } from "./credential.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatk/hatk",
3
- "version": "0.0.1-alpha.100",
3
+ "version": "0.0.1-alpha.101",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "hatk": "dist/cli.js"