@atproto/space 0.0.0-spaces-alpha-20260818022935

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/commit.d.ts +25 -0
  4. package/dist/commit.d.ts.map +1 -0
  5. package/dist/commit.js +80 -0
  6. package/dist/commit.js.map +1 -0
  7. package/dist/credential.d.ts +77 -0
  8. package/dist/credential.d.ts.map +1 -0
  9. package/dist/credential.js +160 -0
  10. package/dist/credential.js.map +1 -0
  11. package/dist/dpop.d.ts +40 -0
  12. package/dist/dpop.d.ts.map +1 -0
  13. package/dist/dpop.js +96 -0
  14. package/dist/dpop.js.map +1 -0
  15. package/dist/error.d.ts +12 -0
  16. package/dist/error.d.ts.map +1 -0
  17. package/dist/error.js +21 -0
  18. package/dist/error.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/lthash.d.ts +18 -0
  24. package/dist/lthash.d.ts.map +1 -0
  25. package/dist/lthash.js +68 -0
  26. package/dist/lthash.js.map +1 -0
  27. package/dist/repo-commit.d.ts +51 -0
  28. package/dist/repo-commit.d.ts.map +1 -0
  29. package/dist/repo-commit.js +137 -0
  30. package/dist/repo-commit.js.map +1 -0
  31. package/dist/space-members.d.ts +27 -0
  32. package/dist/space-members.d.ts.map +1 -0
  33. package/dist/space-members.js +111 -0
  34. package/dist/space-members.js.map +1 -0
  35. package/dist/space-repo.d.ts +33 -0
  36. package/dist/space-repo.d.ts.map +1 -0
  37. package/dist/space-repo.js +136 -0
  38. package/dist/space-repo.js.map +1 -0
  39. package/dist/storage/index.d.ts +3 -0
  40. package/dist/storage/index.d.ts.map +1 -0
  41. package/dist/storage/index.js +19 -0
  42. package/dist/storage/index.js.map +1 -0
  43. package/dist/storage/memory-members-storage.d.ts +12 -0
  44. package/dist/storage/memory-members-storage.d.ts.map +1 -0
  45. package/dist/storage/memory-members-storage.js +36 -0
  46. package/dist/storage/memory-members-storage.js.map +1 -0
  47. package/dist/storage/memory-repo-storage.d.ts +17 -0
  48. package/dist/storage/memory-repo-storage.d.ts.map +1 -0
  49. package/dist/storage/memory-repo-storage.js +56 -0
  50. package/dist/storage/memory-repo-storage.js.map +1 -0
  51. package/dist/storage/types.d.ts +14 -0
  52. package/dist/storage/types.d.ts.map +1 -0
  53. package/dist/storage/types.js +3 -0
  54. package/dist/storage/types.js.map +1 -0
  55. package/dist/sync/car.d.ts +13 -0
  56. package/dist/sync/car.d.ts.map +1 -0
  57. package/dist/sync/car.js +185 -0
  58. package/dist/sync/car.js.map +1 -0
  59. package/dist/sync/consumer.d.ts +39 -0
  60. package/dist/sync/consumer.d.ts.map +1 -0
  61. package/dist/sync/consumer.js +89 -0
  62. package/dist/sync/consumer.js.map +1 -0
  63. package/dist/sync/index.d.ts +3 -0
  64. package/dist/sync/index.d.ts.map +1 -0
  65. package/dist/sync/index.js +3 -0
  66. package/dist/sync/index.js.map +1 -0
  67. package/dist/sync/provider.d.ts +25 -0
  68. package/dist/sync/provider.d.ts.map +1 -0
  69. package/dist/sync/provider.js +60 -0
  70. package/dist/sync/provider.js.map +1 -0
  71. package/dist/sync/repo-index.d.ts +19 -0
  72. package/dist/sync/repo-index.d.ts.map +1 -0
  73. package/dist/sync/repo-index.js +58 -0
  74. package/dist/sync/repo-index.js.map +1 -0
  75. package/dist/types.d.ts +49 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +35 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/util.d.ts +6 -0
  80. package/dist/util.d.ts.map +1 -0
  81. package/dist/util.js +14 -0
  82. package/dist/util.js.map +1 -0
  83. package/package.json +44 -0
  84. package/src/credential.ts +262 -0
  85. package/src/dpop.ts +166 -0
  86. package/src/error.ts +38 -0
  87. package/src/index.ts +8 -0
  88. package/src/lthash.ts +80 -0
  89. package/src/repo-commit.ts +171 -0
  90. package/src/sync/consumer.ts +149 -0
  91. package/src/sync/index.ts +2 -0
  92. package/src/sync/provider.ts +82 -0
  93. package/src/types.ts +62 -0
  94. package/src/util.ts +21 -0
  95. package/tests/credential.test.ts +323 -0
  96. package/tests/dpop.test.ts +234 -0
  97. package/tests/lthash.test.ts +97 -0
  98. package/tests/repo-commit.test.ts +281 -0
  99. package/tests/sync.test.ts +385 -0
  100. package/tsconfig.build.json +16 -0
  101. package/tsconfig.build.tsbuildinfo +1 -0
  102. package/tsconfig.json +7 -0
  103. package/tsconfig.test.json +11 -0
  104. package/tsconfig.test.tsbuildinfo +1 -0
  105. package/tsconfig.tests.tsbuildinfo +1 -0
  106. package/vitest.config.ts +5 -0
@@ -0,0 +1,323 @@
1
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
2
+ import { type Keypair, Secp256k1Keypair } from '@atproto/crypto'
3
+ import {
4
+ SPACE_TOKEN_TYPES,
5
+ SpaceTokenError,
6
+ createSpaceToken,
7
+ parseSpaceToken,
8
+ verifySpaceToken,
9
+ } from '../src/index.js'
10
+
11
+ const SPACE = 'at://did:example:space/space/app.bsky.group/test'
12
+ const USER = 'did:example:alice'
13
+ const AUTHORITY = 'did:example:space'
14
+ const SPACE_HOST = `${AUTHORITY}#atproto_space_host`
15
+ const CLIENT_ID = 'https://app.example.com/client-metadata.json'
16
+ const DPOP_JKT = '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'
17
+
18
+ /** Swap a token's `typ` header, leaving its payload and signature alone. */
19
+ const retypeToken = (jwt: string, typ: string): string => {
20
+ const [headerB64, payloadB64, sigB64] = jwt.split('.')
21
+ const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString())
22
+ const retyped = Buffer.from(JSON.stringify({ ...header, typ })).toString(
23
+ 'base64url',
24
+ )
25
+ return `${retyped}.${payloadB64}.${sigB64}`
26
+ }
27
+
28
+ describe('space tokens', () => {
29
+ let userKey: Keypair
30
+ let authorityKey: Keypair
31
+ let clientKey: Keypair
32
+
33
+ beforeAll(async () => {
34
+ ;[userKey, authorityKey, clientKey] = await Promise.all([
35
+ Secp256k1Keypair.create(),
36
+ Secp256k1Keypair.create(),
37
+ Secp256k1Keypair.create(),
38
+ ])
39
+ })
40
+
41
+ const getKey = (keypair: Keypair) => () => keypair.did()
42
+
43
+ describe('delegation token', () => {
44
+ const create = () =>
45
+ createSpaceToken(
46
+ 'delegation',
47
+ { iss: USER, sub: SPACE, aud: SPACE_HOST },
48
+ userKey,
49
+ )
50
+
51
+ it('round-trips', async () => {
52
+ const { header, payload } = await verifySpaceToken(
53
+ 'delegation',
54
+ await create(),
55
+ { getSigningKey: getKey(userKey), aud: SPACE_HOST, sub: SPACE },
56
+ )
57
+ expect(header.typ).toBe('atproto-space-delegation+jwt')
58
+ expect(header.kid).toBe('#atproto')
59
+ expect(header.alg).toBe(userKey.jwtAlg)
60
+ expect(payload.iss).toBe(USER)
61
+ expect(payload.sub).toBe(SPACE)
62
+ expect(payload.aud).toBe(SPACE_HOST)
63
+ expect(payload.exp - payload.iat).toBe(60)
64
+ expect(payload.jti).toMatch(/^[0-9a-f]{32}$/)
65
+ })
66
+
67
+ it('requires an aud at mint time', async () => {
68
+ await expect(
69
+ createSpaceToken('delegation', { iss: USER, sub: SPACE }, userKey),
70
+ ).rejects.toThrow(/requires an "aud"/)
71
+ })
72
+
73
+ it('is rejected by an authority it was not addressed to', async () => {
74
+ const jwt = await create()
75
+ await expect(
76
+ verifySpaceToken('delegation', jwt, {
77
+ getSigningKey: getKey(userKey),
78
+ aud: 'did:example:other#atproto_space_host',
79
+ }),
80
+ ).rejects.toMatchObject({ code: 'BadJwtAudience' })
81
+ })
82
+
83
+ it('is rejected for a space other than its subject', async () => {
84
+ const jwt = await create()
85
+ await expect(
86
+ verifySpaceToken('delegation', jwt, {
87
+ getSigningKey: getKey(userKey),
88
+ aud: SPACE_HOST,
89
+ sub: 'at://did:example:space/space/app.bsky.group/other',
90
+ }),
91
+ ).rejects.toMatchObject({ code: 'BadJwtSub' })
92
+ })
93
+
94
+ it('is rejected when signed by the wrong key', async () => {
95
+ const jwt = await create()
96
+ await expect(
97
+ verifySpaceToken('delegation', jwt, {
98
+ getSigningKey: getKey(authorityKey),
99
+ aud: SPACE_HOST,
100
+ }),
101
+ ).rejects.toMatchObject({ code: 'BadJwtSignature' })
102
+ })
103
+
104
+ it('is rejected when its type is wrong', async () => {
105
+ const jwt = await create()
106
+ await expect(
107
+ verifySpaceToken('credential', jwt, {
108
+ getSigningKey: getKey(userKey),
109
+ }),
110
+ ).rejects.toMatchObject({ code: 'BadJwtType' })
111
+ })
112
+ })
113
+
114
+ describe('space credential', () => {
115
+ const create = (opts?: { expiresInSec?: number; kid?: string }) =>
116
+ createSpaceToken(
117
+ 'credential',
118
+ { iss: AUTHORITY, sub: SPACE, dpopJkt: DPOP_JKT, ...opts },
119
+ authorityKey,
120
+ )
121
+
122
+ it('round-trips, defaults to 2h, and carries no aud', async () => {
123
+ const { header, payload } = await verifySpaceToken(
124
+ 'credential',
125
+ await create(),
126
+ { getSigningKey: getKey(authorityKey), sub: SPACE },
127
+ )
128
+ expect(header.typ).toBe('atproto-space-credential+jwt')
129
+ expect(header.kid).toBe('#atproto')
130
+ expect(payload.iss).toBe(AUTHORITY)
131
+ expect(payload.aud).toBeUndefined()
132
+ expect(payload.exp - payload.iat).toBe(7200)
133
+ })
134
+
135
+ it('is bound to the requested DPoP key', async () => {
136
+ const { payload } = await verifySpaceToken('credential', await create(), {
137
+ getSigningKey: getKey(authorityKey),
138
+ })
139
+ expect(payload.cnf?.jkt).toBe(DPOP_JKT)
140
+ })
141
+
142
+ it('requires a dpopJkt at mint time', async () => {
143
+ await expect(
144
+ createSpaceToken(
145
+ 'credential',
146
+ { iss: AUTHORITY, sub: SPACE },
147
+ authorityKey,
148
+ ),
149
+ ).rejects.toThrow(/requires a "dpopJkt"/)
150
+ })
151
+
152
+ it('is rejected when it carries no binding', async () => {
153
+ const unbound = await createSpaceToken(
154
+ 'delegation',
155
+ { iss: AUTHORITY, sub: SPACE, aud: SPACE_HOST },
156
+ authorityKey,
157
+ )
158
+ const forgedTyp = retypeToken(unbound, SPACE_TOKEN_TYPES.credential.typ)
159
+ expect(() => parseSpaceToken('credential', forgedTyp)).toThrow(
160
+ /missing token "cnf.jkt"/,
161
+ )
162
+ })
163
+
164
+ it('passes iss and kid to the key resolver', async () => {
165
+ const getSigningKey = vi.fn(() => authorityKey.did())
166
+ await verifySpaceToken('credential', await create(), { getSigningKey })
167
+ expect(getSigningKey).toHaveBeenCalledWith(AUTHORITY, '#atproto', false)
168
+ })
169
+
170
+ it('honours a kid override for authorities with a dedicated space key', async () => {
171
+ const getSigningKey = vi.fn(() => authorityKey.did())
172
+ const jwt = await create({ kid: '#atproto_space' })
173
+ await verifySpaceToken('credential', jwt, { getSigningKey })
174
+ expect(getSigningKey).toHaveBeenCalledWith(
175
+ AUTHORITY,
176
+ '#atproto_space',
177
+ false,
178
+ )
179
+ })
180
+
181
+ it('retries with a freshly resolved key when the signing key has rotated', async () => {
182
+ const rotatedKey = await Secp256k1Keypair.create()
183
+ const jwt = await createSpaceToken(
184
+ 'credential',
185
+ { iss: AUTHORITY, sub: SPACE, dpopJkt: DPOP_JKT },
186
+ rotatedKey,
187
+ )
188
+ // First resolution returns the stale key, the retry the rotated one.
189
+ const getSigningKey = vi
190
+ .fn()
191
+ .mockResolvedValueOnce(authorityKey.did())
192
+ .mockResolvedValueOnce(rotatedKey.did())
193
+
194
+ const { payload } = await verifySpaceToken('credential', jwt, {
195
+ getSigningKey,
196
+ })
197
+ expect(payload.iss).toBe(AUTHORITY)
198
+ expect(getSigningKey).toHaveBeenNthCalledWith(
199
+ 1,
200
+ AUTHORITY,
201
+ '#atproto',
202
+ false,
203
+ )
204
+ expect(getSigningKey).toHaveBeenNthCalledWith(
205
+ 2,
206
+ AUTHORITY,
207
+ '#atproto',
208
+ true,
209
+ )
210
+ })
211
+
212
+ it('does not retry when the resolved key is unchanged', async () => {
213
+ const otherKey = await Secp256k1Keypair.create()
214
+ const jwt = await createSpaceToken(
215
+ 'credential',
216
+ { iss: AUTHORITY, sub: SPACE, dpopJkt: DPOP_JKT },
217
+ otherKey,
218
+ )
219
+ const getSigningKey = vi.fn(() => authorityKey.did())
220
+ await expect(
221
+ verifySpaceToken('credential', jwt, { getSigningKey }),
222
+ ).rejects.toMatchObject({ code: 'BadJwtSignature' })
223
+ expect(getSigningKey).toHaveBeenCalledTimes(2)
224
+ })
225
+
226
+ it('rejects an expired credential', async () => {
227
+ const jwt = await create({ expiresInSec: 1 })
228
+ vi.useFakeTimers()
229
+ try {
230
+ vi.setSystemTime(Date.now() + 60_000)
231
+ await expect(
232
+ verifySpaceToken('credential', jwt, {
233
+ getSigningKey: getKey(authorityKey),
234
+ }),
235
+ ).rejects.toMatchObject({ code: 'JwtExpired' })
236
+ } finally {
237
+ vi.useRealTimers()
238
+ }
239
+ })
240
+
241
+ it('tolerates small clock skew', async () => {
242
+ const jwt = await create({ expiresInSec: 1 })
243
+ vi.useFakeTimers()
244
+ try {
245
+ // 2s past exp — inside the 5s skew allowance.
246
+ vi.setSystemTime(Date.now() + 3_000)
247
+ await expect(
248
+ verifySpaceToken('credential', jwt, {
249
+ getSigningKey: getKey(authorityKey),
250
+ }),
251
+ ).resolves.toBeTruthy()
252
+ } finally {
253
+ vi.useRealTimers()
254
+ }
255
+ })
256
+ })
257
+
258
+ describe('client attestation', () => {
259
+ const create = () =>
260
+ createSpaceToken(
261
+ 'clientAttestation',
262
+ { iss: CLIENT_ID, sub: CLIENT_ID, aud: SPACE_HOST, kid: 'key-1' },
263
+ clientKey,
264
+ )
265
+
266
+ it('parses without needing a key', async () => {
267
+ const { header, payload } = parseSpaceToken(
268
+ 'clientAttestation',
269
+ await create(),
270
+ )
271
+ expect(header.typ).toBe('atproto-client-attestation+jwt')
272
+ expect(header.kid).toBe('key-1')
273
+ expect(payload.iss).toBe(CLIENT_ID)
274
+ expect(payload.sub).toBe(CLIENT_ID)
275
+ expect(payload.aud).toBe(SPACE_HOST)
276
+ })
277
+
278
+ it('requires iss and sub to both be the client_id', async () => {
279
+ const jwt = await createSpaceToken(
280
+ 'clientAttestation',
281
+ { iss: CLIENT_ID, sub: 'https://other.example/x', aud: SPACE_HOST },
282
+ clientKey,
283
+ )
284
+ expect(() => parseSpaceToken('clientAttestation', jwt)).toThrow(
285
+ /must both be the client_id/,
286
+ )
287
+ })
288
+
289
+ it('verifies against the client key when one is resolvable', async () => {
290
+ const { payload } = await verifySpaceToken(
291
+ 'clientAttestation',
292
+ await create(),
293
+ { getSigningKey: getKey(clientKey), aud: SPACE_HOST },
294
+ )
295
+ expect(payload.iss).toBe(CLIENT_ID)
296
+ })
297
+ })
298
+
299
+ describe('malformed tokens', () => {
300
+ it.each([
301
+ ['not a jwt', 'nope'],
302
+ ['too few parts', 'aaa.bbb'],
303
+ ['bad base64 header', '!!!.e30.c2ln'],
304
+ ])('rejects %s', (_name, jwt) => {
305
+ expect(() => parseSpaceToken('credential', jwt)).toThrow(SpaceTokenError)
306
+ })
307
+
308
+ it('rejects a token missing required claims', async () => {
309
+ const header = Buffer.from(
310
+ JSON.stringify({
311
+ alg: 'ES256K',
312
+ typ: SPACE_TOKEN_TYPES.credential.typ,
313
+ }),
314
+ ).toString('base64url')
315
+ const payload = Buffer.from(JSON.stringify({ iss: AUTHORITY })).toString(
316
+ 'base64url',
317
+ )
318
+ expect(() =>
319
+ parseSpaceToken('credential', `${header}.${payload}.c2ln`),
320
+ ).toThrow(/missing token "sub"/)
321
+ })
322
+ })
323
+ })
@@ -0,0 +1,234 @@
1
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
2
+ import { sha256 } from '@atproto/crypto'
3
+ import { JoseKey } from '@atproto/jwk-jose'
4
+ import { toBase64 } from '@atproto/lex-data'
5
+ import {
6
+ MAX_PROOF_AGE_SEC,
7
+ createDpopProof,
8
+ dpopJktForKey,
9
+ verifyDpopProof,
10
+ } from '../src/index.js'
11
+
12
+ const CREDENTIAL = 'eyJ0eXAiOiJhdHByb3RvLXNwYWNlLWNyZWRlbnRpYWwrand0'
13
+ const HTU = 'https://pds.example.com/xrpc/com.atproto.space.getRepo'
14
+ const HTM = 'GET'
15
+ const EXCHANGE_HTU =
16
+ 'https://space.example.com/xrpc/com.atproto.space.getSpaceCredential'
17
+
18
+ const athFor = async (credential: string): Promise<string> =>
19
+ toBase64(await sha256(credential), 'base64url')
20
+
21
+ describe('DPoP proofs', () => {
22
+ let key: JoseKey
23
+ let otherKey: JoseKey
24
+
25
+ beforeAll(async () => {
26
+ ;[key, otherKey] = await Promise.all([
27
+ JoseKey.generate(['ES256']),
28
+ JoseKey.generate(['ES256']),
29
+ ])
30
+ })
31
+
32
+ const verifyAgainst = (
33
+ proof: string,
34
+ overrides: Partial<Parameters<typeof verifyDpopProof>[1]> = {},
35
+ ) =>
36
+ verifyDpopProof(proof, {
37
+ htm: HTM,
38
+ htu: HTU,
39
+ credential: CREDENTIAL,
40
+ jkt: overrides.jkt ?? '',
41
+ ...overrides,
42
+ })
43
+
44
+ it('round-trips a proof for the request it was minted for', async () => {
45
+ const jkt = await dpopJktForKey(key)
46
+ const proof = await createDpopProof(key, {
47
+ htm: HTM,
48
+ htu: HTU,
49
+ credential: CREDENTIAL,
50
+ })
51
+
52
+ const verified = await verifyAgainst(proof, { jkt })
53
+ expect(verified.jkt).toBe(jkt)
54
+ expect(verified.htm).toBe(HTM)
55
+ expect(verified.htu).toBe(HTU)
56
+ expect(verified.jti).toBeTruthy()
57
+ })
58
+
59
+ it('derives a key binding from a credential exchange proof without ath', async () => {
60
+ const proof = await createDpopProof(key, {
61
+ htm: 'POST',
62
+ htu: EXCHANGE_HTU,
63
+ })
64
+
65
+ await expect(
66
+ verifyDpopProof(proof, { htm: 'POST', htu: EXCHANGE_HTU }),
67
+ ).resolves.toMatchObject({ jkt: await dpopJktForKey(key) })
68
+ })
69
+
70
+ it('refuses ath on a credential exchange proof', async () => {
71
+ const proof = await createDpopProof(key, {
72
+ htm: 'POST',
73
+ htu: EXCHANGE_HTU,
74
+ credential: CREDENTIAL,
75
+ })
76
+
77
+ await expect(
78
+ verifyDpopProof(proof, { htm: 'POST', htu: EXCHANGE_HTU }),
79
+ ).rejects.toThrow(/"ath" must be omitted/)
80
+ })
81
+
82
+ it('mints a distinct jti per proof, so a verifier can reject replays', async () => {
83
+ const opts = { htm: HTM, htu: HTU, credential: CREDENTIAL }
84
+ const first = await verifyAgainst(await createDpopProof(key, opts), {
85
+ jkt: await dpopJktForKey(key),
86
+ })
87
+ const second = await verifyAgainst(await createDpopProof(key, opts), {
88
+ jkt: await dpopJktForKey(key),
89
+ })
90
+ expect(first.jti).not.toBe(second.jti)
91
+ })
92
+
93
+ it('strips query and fragment from htu, on both sides', async () => {
94
+ const proof = await createDpopProof(key, {
95
+ htm: HTM,
96
+ htu: `${HTU}?space=at%3A%2F%2Fdid%3Aexample%3Aspace&repo=did%3Aexample%3Abob`,
97
+ credential: CREDENTIAL,
98
+ })
99
+ const verified = await verifyAgainst(proof, {
100
+ jkt: await dpopJktForKey(key),
101
+ htu: `${HTU}?space=something&repo=else`,
102
+ })
103
+ expect(verified.htu).toBe(HTU)
104
+ })
105
+
106
+ it('refuses a proof signed by a key the credential is not bound to', async () => {
107
+ const proof = await createDpopProof(otherKey, {
108
+ htm: HTM,
109
+ htu: HTU,
110
+ credential: CREDENTIAL,
111
+ })
112
+ await expect(
113
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
114
+ ).rejects.toMatchObject({ code: 'DpopKeyMismatch' })
115
+ })
116
+
117
+ it('refuses a proof addressed to another host', async () => {
118
+ const proof = await createDpopProof(key, {
119
+ htm: HTM,
120
+ htu: 'https://other-pds.example.com/xrpc/com.atproto.space.getRepo',
121
+ credential: CREDENTIAL,
122
+ })
123
+ await expect(
124
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
125
+ ).rejects.toThrow(/"htu" does not match/)
126
+ })
127
+
128
+ it('refuses a proof minted for another method', async () => {
129
+ const proof = await createDpopProof(key, {
130
+ htm: 'POST',
131
+ htu: HTU,
132
+ credential: CREDENTIAL,
133
+ })
134
+ await expect(
135
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
136
+ ).rejects.toThrow(/"htm" does not match/)
137
+ })
138
+
139
+ it('refuses a proof paired with a different credential', async () => {
140
+ const proof = await createDpopProof(key, {
141
+ htm: HTM,
142
+ htu: HTU,
143
+ credential: 'a-different-credential',
144
+ })
145
+ await expect(
146
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
147
+ ).rejects.toThrow(/"ath" does not match/)
148
+ })
149
+
150
+ it('refuses a stale proof, as expired rather than unverifiable', async () => {
151
+ const proof = await createDpopProof(key, {
152
+ htm: HTM,
153
+ htu: HTU,
154
+ credential: CREDENTIAL,
155
+ })
156
+ const jkt = await dpopJktForKey(key)
157
+
158
+ vi.useFakeTimers()
159
+ try {
160
+ vi.setSystemTime(Date.now() + (MAX_PROOF_AGE_SEC + 60) * 1000)
161
+ await expect(verifyAgainst(proof, { jkt })).rejects.toMatchObject({
162
+ code: 'DpopProofExpired',
163
+ })
164
+ } finally {
165
+ vi.useRealTimers()
166
+ }
167
+ })
168
+
169
+ describe('clock skew', () => {
170
+ /** Mint a proof as though this host's clock were `offsetMs` off from ours. */
171
+ const proofWithClockOffset = async (offsetMs: number): Promise<string> => {
172
+ vi.useFakeTimers()
173
+ try {
174
+ vi.setSystemTime(Date.now() + offsetMs)
175
+ return await createDpopProof(key, {
176
+ htm: HTM,
177
+ htu: HTU,
178
+ credential: CREDENTIAL,
179
+ })
180
+ } finally {
181
+ vi.useRealTimers()
182
+ }
183
+ }
184
+
185
+ it('accepts a proof from a holder whose clock runs slightly fast', async () => {
186
+ // Regression: without clockTolerance this is a hard 401, and only between real
187
+ // machines — it looks like an intermittent auth error.
188
+ const proof = await proofWithClockOffset(2_000)
189
+ await expect(
190
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
191
+ ).resolves.toBeTruthy()
192
+ })
193
+
194
+ it('accepts a proof from a holder whose clock runs slightly slow', async () => {
195
+ const proof = await proofWithClockOffset(-2_000)
196
+ await expect(
197
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
198
+ ).resolves.toBeTruthy()
199
+ })
200
+
201
+ it('still refuses a proof dated far in the future', async () => {
202
+ const proof = await proofWithClockOffset((MAX_PROOF_AGE_SEC + 60) * 1000)
203
+ await expect(
204
+ verifyAgainst(proof, { jkt: await dpopJktForKey(key) }),
205
+ ).rejects.toMatchObject({ code: 'BadDpopProofSignature' })
206
+ })
207
+ })
208
+
209
+ it('refuses a proof signed with an algorithm outside the allowed set', async () => {
210
+ // Signed directly rather than through createDpopProof, which refuses the key
211
+ // outright. The signature is valid and the thumbprint matches, so only the
212
+ // algorithm restriction stands between this proof and being honoured.
213
+ const rsaKey = await JoseKey.generate(['RS256'])
214
+ const proof = await rsaKey.createJwt(
215
+ { alg: 'RS256', typ: 'dpop+jwt', jwk: rsaKey.bareJwk },
216
+ {
217
+ jti: 'rsa-proof',
218
+ htm: HTM,
219
+ htu: HTU,
220
+ ath: await athFor(CREDENTIAL),
221
+ iat: Math.floor(Date.now() / 1000),
222
+ },
223
+ )
224
+ await expect(
225
+ verifyAgainst(proof, { jkt: await dpopJktForKey(rsaKey) }),
226
+ ).rejects.toMatchObject({ code: 'BadDpopProofSignature' })
227
+ })
228
+
229
+ it('refuses a token that is not a proof', async () => {
230
+ await expect(
231
+ verifyAgainst(CREDENTIAL, { jkt: await dpopJktForKey(key) }),
232
+ ).rejects.toMatchObject({ code: 'BadDpopProofSignature' })
233
+ })
234
+ })
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { LTHASH_STATE_BYTES, LtHash } from '../src/index.js'
3
+
4
+ const ZEROS = new Uint8Array(LTHASH_STATE_BYTES)
5
+
6
+ describe('LtHash', () => {
7
+ it('starts as 2048 zero bytes', () => {
8
+ expect(new LtHash().state()).toEqual(ZEROS)
9
+ expect(new LtHash().isEmpty()).toBe(true)
10
+ })
11
+
12
+ it('add then remove returns to the zero state', () => {
13
+ const h = new LtHash()
14
+ h.add('a')
15
+ expect(h.state()).not.toEqual(ZEROS)
16
+ h.remove('a')
17
+ expect(h.state()).toEqual(ZEROS)
18
+ })
19
+
20
+ it('is order-independent', () => {
21
+ const a = new LtHash().add('a').add('b')
22
+ const b = new LtHash().add('b').add('a')
23
+ expect(a.equals(b)).toBe(true)
24
+ expect(a.digest()).toEqual(b.digest())
25
+ })
26
+
27
+ it('distinguishes different elements', () => {
28
+ expect(new LtHash().add('a').equals(new LtHash().add('b'))).toBe(false)
29
+ })
30
+
31
+ it('is a multiset — a double add does not cancel out', () => {
32
+ const h = new LtHash().add('a').add('a')
33
+ expect(h.isEmpty()).toBe(false)
34
+ h.remove('a')
35
+ expect(h.equals(new LtHash().add('a'))).toBe(true)
36
+ })
37
+
38
+ describe('state round-trips', () => {
39
+ it('resumes from persisted state', () => {
40
+ const a = new LtHash().add('a').add('b')
41
+ expect(new LtHash(a.state()).equals(a)).toBe(true)
42
+ })
43
+
44
+ it('treats nullish state as empty', () => {
45
+ expect(new LtHash(null).isEmpty()).toBe(true)
46
+ expect(new LtHash(undefined).isEmpty()).toBe(true)
47
+ })
48
+
49
+ it('rejects wrong-length state', () => {
50
+ expect(() => new LtHash(new Uint8Array(32))).toThrow(/must be 2048 bytes/)
51
+ })
52
+
53
+ it('does not alias the state it was built from', () => {
54
+ const state = new Uint8Array(LTHASH_STATE_BYTES)
55
+ state[0] = 0xff
56
+ const h = new LtHash(state)
57
+ state[0] = 0x00
58
+ expect(h.state()[0]).toBe(0xff)
59
+ })
60
+
61
+ it('does not alias the state it hands out', () => {
62
+ const h = new LtHash().add('a')
63
+ const state = h.state()
64
+ state[0] ^= 0xff
65
+ expect(h.state()).not.toEqual(state)
66
+ })
67
+ })
68
+
69
+ // How a caller stages a change without committing it: build from `state()`,
70
+ // mutate the copy, and leave the original where it was.
71
+ it('a hash built from another state does not share it', () => {
72
+ const original = new LtHash().add('a')
73
+ const staged = new LtHash(original.state()).add('b')
74
+ expect(original.equals(staged)).toBe(false)
75
+ expect(original.equals(new LtHash().add('a'))).toBe(true)
76
+ })
77
+
78
+ describe('digest', () => {
79
+ it('is 32 bytes, and sha256 of the zero state when empty', () => {
80
+ const digest = new LtHash().digest()
81
+ expect(digest).toHaveLength(32)
82
+ expect(Buffer.from(digest).toString('hex')).toBe(
83
+ 'e5a00aa9991ac8a5ee3109844d84a55583bd20572ad3ffcd42792f3c36b183ad',
84
+ )
85
+ })
86
+
87
+ it('snapshot vector locks the algorithm', () => {
88
+ // 1024 lanes of u16 mod 2^16, elements expanded with BLAKE3 XOF at
89
+ // dkLen=2048, summed lane-wise. If this changes, persisted state must be
90
+ // recomputed.
91
+ const h = new LtHash().add('one').add('two')
92
+ expect(Buffer.from(h.digest()).toString('hex')).toBe(
93
+ 'ae05cb6d224379d9710c290c8529945c5b0e0fde9ead30b9699057ce701c63e7',
94
+ )
95
+ })
96
+ })
97
+ })