@oxy.so/protocol 1.0.0
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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/chain/continuity.js +54 -0
- package/dist/cjs/chain/engine.js +34 -0
- package/dist/cjs/chain/recordStore.js +25 -0
- package/dist/cjs/chain/types.js +22 -0
- package/dist/cjs/chain/verify.js +82 -0
- package/dist/cjs/envelope/canonicalJson.js +107 -0
- package/dist/cjs/envelope/recordId.js +60 -0
- package/dist/cjs/envelope/sign.js +75 -0
- package/dist/cjs/envelope/signingInput.js +32 -0
- package/dist/cjs/identity/resolver.js +50 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/node/constants.js +85 -0
- package/dist/cjs/node/didWebResolver.js +126 -0
- package/dist/cjs/node/httpFetch.js +61 -0
- package/dist/cjs/node/index.js +71 -0
- package/dist/cjs/node/nodeApp.js +344 -0
- package/dist/cjs/node/nodeClient.js +204 -0
- package/dist/cjs/node/rateLimit.js +187 -0
- package/dist/cjs/node/verifyRecord.js +51 -0
- package/dist/cjs/platform/crypto.js +186 -0
- package/dist/cjs/platform/crypto.native.js +204 -0
- package/dist/cjs/platform/expoTypes.js +24 -0
- package/dist/cjs/platform/platform.js +33 -0
- package/dist/cjs/secp256k1.js +148 -0
- package/dist/cjs/transparency/checkpoint.js +79 -0
- package/dist/cjs/transparency/tree.js +197 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/chain/continuity.js +51 -0
- package/dist/esm/chain/engine.js +31 -0
- package/dist/esm/chain/recordStore.js +24 -0
- package/dist/esm/chain/types.js +19 -0
- package/dist/esm/chain/verify.js +78 -0
- package/dist/esm/envelope/canonicalJson.js +104 -0
- package/dist/esm/envelope/recordId.js +56 -0
- package/dist/esm/envelope/sign.js +69 -0
- package/dist/esm/envelope/signingInput.js +29 -0
- package/dist/esm/identity/resolver.js +47 -0
- package/dist/esm/index.js +36 -0
- package/dist/esm/node/constants.js +82 -0
- package/dist/esm/node/didWebResolver.js +122 -0
- package/dist/esm/node/httpFetch.js +55 -0
- package/dist/esm/node/index.js +28 -0
- package/dist/esm/node/nodeApp.js +336 -0
- package/dist/esm/node/nodeClient.js +198 -0
- package/dist/esm/node/rateLimit.js +182 -0
- package/dist/esm/node/verifyRecord.js +48 -0
- package/dist/esm/platform/crypto.js +145 -0
- package/dist/esm/platform/crypto.native.js +196 -0
- package/dist/esm/platform/expoTypes.js +23 -0
- package/dist/esm/platform/platform.js +29 -0
- package/dist/esm/secp256k1.js +137 -0
- package/dist/esm/transparency/checkpoint.js +73 -0
- package/dist/esm/transparency/tree.js +189 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/chain/continuity.d.ts +28 -0
- package/dist/types/chain/engine.d.ts +27 -0
- package/dist/types/chain/recordStore.d.ts +85 -0
- package/dist/types/chain/types.d.ts +79 -0
- package/dist/types/chain/verify.d.ts +45 -0
- package/dist/types/envelope/canonicalJson.d.ts +44 -0
- package/dist/types/envelope/recordId.d.ts +30 -0
- package/dist/types/envelope/sign.d.ts +47 -0
- package/dist/types/envelope/signingInput.d.ts +33 -0
- package/dist/types/identity/resolver.d.ts +67 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/node/constants.d.ts +80 -0
- package/dist/types/node/didWebResolver.d.ts +47 -0
- package/dist/types/node/httpFetch.d.ts +60 -0
- package/dist/types/node/index.d.ts +28 -0
- package/dist/types/node/nodeApp.d.ts +120 -0
- package/dist/types/node/nodeClient.d.ts +135 -0
- package/dist/types/node/rateLimit.d.ts +95 -0
- package/dist/types/node/verifyRecord.d.ts +41 -0
- package/dist/types/platform/crypto.d.ts +93 -0
- package/dist/types/platform/crypto.native.d.ts +77 -0
- package/dist/types/platform/expoTypes.d.ts +99 -0
- package/dist/types/platform/platform.d.ts +25 -0
- package/dist/types/secp256k1.d.ts +45 -0
- package/dist/types/transparency/checkpoint.d.ts +71 -0
- package/dist/types/transparency/tree.d.ts +135 -0
- package/package.json +157 -0
- package/src/__tests__/canonicalJson.test.ts +116 -0
- package/src/__tests__/chain.test.ts +279 -0
- package/src/__tests__/didWebResolver.test.ts +132 -0
- package/src/__tests__/envelope.test.ts +267 -0
- package/src/__tests__/nodeApp.test.ts +410 -0
- package/src/__tests__/nodeClient.test.ts +177 -0
- package/src/__tests__/nodeHarness.ts +151 -0
- package/src/__tests__/optionalNativePeers.test.ts +233 -0
- package/src/__tests__/rateLimit.test.ts +268 -0
- package/src/__tests__/runnerGuard.test.ts +85 -0
- package/src/__tests__/secp256k1.test.ts +118 -0
- package/src/__tests__/transparency.test.ts +353 -0
- package/src/chain/continuity.ts +59 -0
- package/src/chain/engine.ts +43 -0
- package/src/chain/recordStore.ts +98 -0
- package/src/chain/types.ts +85 -0
- package/src/chain/verify.ts +102 -0
- package/src/envelope/canonicalJson.ts +120 -0
- package/src/envelope/recordId.ts +63 -0
- package/src/envelope/sign.ts +86 -0
- package/src/envelope/signingInput.ts +48 -0
- package/src/identity/resolver.ts +90 -0
- package/src/index.ts +101 -0
- package/src/node/constants.ts +105 -0
- package/src/node/didWebResolver.ts +162 -0
- package/src/node/httpFetch.ts +88 -0
- package/src/node/index.ts +87 -0
- package/src/node/nodeApp.ts +471 -0
- package/src/node/nodeClient.ts +322 -0
- package/src/node/rateLimit.ts +233 -0
- package/src/node/verifyRecord.ts +60 -0
- package/src/platform/crypto.native.ts +251 -0
- package/src/platform/crypto.ts +172 -0
- package/src/platform/expoTypes.ts +99 -0
- package/src/platform/platform.ts +31 -0
- package/src/secp256k1.ts +207 -0
- package/src/transparency/checkpoint.ts +109 -0
- package/src/transparency/tree.ts +258 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createRateLimiter` unit tests — exercise the limiter directly (no Express
|
|
3
|
+
* server) so per-key state and the memory-bounding behaviour are deterministic.
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* - correct fixed-window budgeting for a legitimate client (allow up to `max`,
|
|
7
|
+
* then 429, then reset after the window),
|
|
8
|
+
* - the hard-cap LRU backstop: a burst of distinct keys never grows the tracked
|
|
9
|
+
* map past `maxEntries`,
|
|
10
|
+
* - the active sweep: expired windows are reclaimed on the timer even when their
|
|
11
|
+
* keys are never touched again,
|
|
12
|
+
* - `stop()` clears the sweep timer,
|
|
13
|
+
* - the tracked key is a salted hash of the client address rather than the
|
|
14
|
+
* address itself, on an ephemeral per-process salt — and the limiter actually
|
|
15
|
+
* goes through that hasher.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
19
|
+
import { clientRateLimitKey, createRateLimiter, type RateLimiter } from '../node/rateLimit';
|
|
20
|
+
|
|
21
|
+
/** A minimal `Response` double capturing the status/headers/body the limiter sets. */
|
|
22
|
+
interface ResponseSpy {
|
|
23
|
+
res: Response;
|
|
24
|
+
statusCode: number | null;
|
|
25
|
+
body: unknown;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeResponseSpy(): ResponseSpy {
|
|
30
|
+
const spy: ResponseSpy = { res: {} as Response, statusCode: null, body: undefined, headers: {} };
|
|
31
|
+
const res: Pick<Response, 'status' | 'json' | 'setHeader'> = {
|
|
32
|
+
status(code: number) {
|
|
33
|
+
spy.statusCode = code;
|
|
34
|
+
return res as Response;
|
|
35
|
+
},
|
|
36
|
+
json(payload: unknown) {
|
|
37
|
+
spy.body = payload;
|
|
38
|
+
return res as Response;
|
|
39
|
+
},
|
|
40
|
+
setHeader(name: string, value: string | number | readonly string[]) {
|
|
41
|
+
spy.headers[name] = String(value);
|
|
42
|
+
return res as Response;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
spy.res = res as Response;
|
|
46
|
+
return spy;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drive one request through the limiter for `ip`; returns whether `next()` ran.
|
|
51
|
+
* `ip` is optional so a request Express resolved no address for — the sentinel
|
|
52
|
+
* path — can be driven through the same helper.
|
|
53
|
+
*/
|
|
54
|
+
function call(limiter: RateLimiter, ip?: string): { passed: boolean; response: ResponseSpy } {
|
|
55
|
+
const req = { ip } as Request;
|
|
56
|
+
const response = makeResponseSpy();
|
|
57
|
+
let passed = false;
|
|
58
|
+
const next: NextFunction = () => {
|
|
59
|
+
passed = true;
|
|
60
|
+
};
|
|
61
|
+
limiter(req, response.res, next);
|
|
62
|
+
return { passed, response };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('createRateLimiter', () => {
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
jest.useRealTimers();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('allows up to `max` requests per window then responds 429 rate_limited', () => {
|
|
71
|
+
const limiter = createRateLimiter({ windowMs: 60_000, max: 3 });
|
|
72
|
+
try {
|
|
73
|
+
expect(call(limiter, '1.1.1.1').passed).toBe(true);
|
|
74
|
+
expect(call(limiter, '1.1.1.1').passed).toBe(true);
|
|
75
|
+
expect(call(limiter, '1.1.1.1').passed).toBe(true);
|
|
76
|
+
|
|
77
|
+
const fourth = call(limiter, '1.1.1.1');
|
|
78
|
+
expect(fourth.passed).toBe(false);
|
|
79
|
+
expect(fourth.response.statusCode).toBe(429);
|
|
80
|
+
expect(fourth.response.body).toEqual({ error: 'rate_limited' });
|
|
81
|
+
expect(Number(fourth.response.headers['Retry-After'])).toBeGreaterThanOrEqual(1);
|
|
82
|
+
} finally {
|
|
83
|
+
limiter.stop();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('budgets each client key independently', () => {
|
|
88
|
+
const limiter = createRateLimiter({ windowMs: 60_000, max: 1 });
|
|
89
|
+
try {
|
|
90
|
+
expect(call(limiter, 'a').passed).toBe(true);
|
|
91
|
+
expect(call(limiter, 'a').passed).toBe(false); // a exhausted
|
|
92
|
+
expect(call(limiter, 'b').passed).toBe(true); // b independent
|
|
93
|
+
} finally {
|
|
94
|
+
limiter.stop();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('resets a key after its window elapses', () => {
|
|
99
|
+
jest.useFakeTimers();
|
|
100
|
+
const limiter = createRateLimiter({ windowMs: 1_000, max: 1 });
|
|
101
|
+
try {
|
|
102
|
+
expect(call(limiter, 'x').passed).toBe(true);
|
|
103
|
+
expect(call(limiter, 'x').passed).toBe(false);
|
|
104
|
+
jest.advanceTimersByTime(1_001);
|
|
105
|
+
expect(call(limiter, 'x').passed).toBe(true); // fresh window
|
|
106
|
+
} finally {
|
|
107
|
+
limiter.stop();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('bounds memory: a burst of distinct keys never exceeds maxEntries (LRU eviction)', () => {
|
|
112
|
+
// With max:1, a SURVIVING key is rate-limited on its second hit, while an
|
|
113
|
+
// EVICTED key behaves brand new (passes). That makes eviction observable
|
|
114
|
+
// without reaching into the private map. All requests stay within one window
|
|
115
|
+
// (no sweep), so only the hard cap can bound the tracked set.
|
|
116
|
+
const maxEntries = 50;
|
|
117
|
+
const limiter = createRateLimiter({ windowMs: 60_000, max: 1, maxEntries });
|
|
118
|
+
try {
|
|
119
|
+
// Seed the oldest key, exhausting its single-request budget.
|
|
120
|
+
expect(call(limiter, 'oldest').passed).toBe(true);
|
|
121
|
+
expect(call(limiter, 'oldest').passed).toBe(false); // exhausted, still tracked
|
|
122
|
+
|
|
123
|
+
// Burst well past the cap with distinct keys. Each insertion past the cap
|
|
124
|
+
// evicts the oldest-inserted entry — 'oldest' is the first to go.
|
|
125
|
+
for (let i = 0; i < maxEntries * 4; i += 1) {
|
|
126
|
+
expect(call(limiter, `flood-${i}`).passed).toBe(true);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 'oldest' was evicted by the cap, so it now passes as a brand-new key —
|
|
130
|
+
// proving the tracked set was bounded rather than growing unboundedly.
|
|
131
|
+
expect(call(limiter, 'oldest').passed).toBe(true);
|
|
132
|
+
} finally {
|
|
133
|
+
limiter.stop();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('actively sweeps expired windows even for keys never touched again', () => {
|
|
138
|
+
jest.useFakeTimers();
|
|
139
|
+
// A burst of keys, then total silence: the active timer must still reclaim
|
|
140
|
+
// them. With max:1, a surviving key would be rate-limited on its next hit;
|
|
141
|
+
// a swept (reclaimed) key passes again as brand new.
|
|
142
|
+
const limiter = createRateLimiter({ windowMs: 1_000, max: 1 });
|
|
143
|
+
try {
|
|
144
|
+
for (let i = 0; i < 100; i += 1) {
|
|
145
|
+
expect(call(limiter, `burst-${i}`).passed).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
// Exhaust one specific key so survival is observable.
|
|
148
|
+
expect(call(limiter, 'burst-0').passed).toBe(false); // exhausted within window
|
|
149
|
+
|
|
150
|
+
// Advance past the window: the unref'd interval fires (>= windowMs) and
|
|
151
|
+
// deletes every expired entry — no further request needed to trigger it.
|
|
152
|
+
jest.advanceTimersByTime(1_500);
|
|
153
|
+
|
|
154
|
+
// The previously-exhausted key was reclaimed by the sweep → passes again.
|
|
155
|
+
expect(call(limiter, 'burst-0').passed).toBe(true);
|
|
156
|
+
} finally {
|
|
157
|
+
limiter.stop();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The limiter's Map used to be keyed on `req.ip ?? 'unknown'` — a raw client
|
|
163
|
+
* address, in memory, for the life of a window. Oxy persists no user IP in any
|
|
164
|
+
* form and the invariant has no in-memory exemption, so the key is now a salted
|
|
165
|
+
* hash. These cases are what makes that statement checkable rather than a
|
|
166
|
+
* comment: reverting the key to the address turns the first of them red.
|
|
167
|
+
*/
|
|
168
|
+
describe('the tracked key is a salted hash of the address, never the address', () => {
|
|
169
|
+
const ADDRESS = '203.0.113.7';
|
|
170
|
+
|
|
171
|
+
it('never yields the address itself', () => {
|
|
172
|
+
const key = clientRateLimitKey({ ip: ADDRESS } as Request);
|
|
173
|
+
|
|
174
|
+
expect(key).not.toBe(ADDRESS);
|
|
175
|
+
// A hex digest cannot contain a dotted quad or a colonned v6 literal, so
|
|
176
|
+
// this holds for any salt rather than for the one this process happened to
|
|
177
|
+
// draw. (A digest CAN contain '203' — hence the whole literal, not a piece.)
|
|
178
|
+
expect(key).not.toContain(ADDRESS);
|
|
179
|
+
expect(clientRateLimitKey({ ip: '2001:db8::1' } as Request)).not.toContain('2001:db8::1');
|
|
180
|
+
expect(key).toMatch(/^[0-9a-f]{24}$/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('is stable for one address and distinct across addresses', () => {
|
|
184
|
+
// The CONTROL for the ephemerality case below: a key that changed per call
|
|
185
|
+
// would satisfy "a different key after a reload" while making the limiter
|
|
186
|
+
// count nothing, and a key that collapsed every address into one constant
|
|
187
|
+
// would satisfy "not the address" while budgeting the whole internet as one
|
|
188
|
+
// client.
|
|
189
|
+
expect(clientRateLimitKey({ ip: ADDRESS } as Request)).toBe(
|
|
190
|
+
clientRateLimitKey({ ip: ADDRESS } as Request),
|
|
191
|
+
);
|
|
192
|
+
expect(clientRateLimitKey({ ip: '203.0.113.8' } as Request)).not.toBe(
|
|
193
|
+
clientRateLimitKey({ ip: ADDRESS } as Request),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('draws a fresh salt per process, so the same address hashes differently after a restart', async () => {
|
|
198
|
+
const before = clientRateLimitKey({ ip: ADDRESS } as Request);
|
|
199
|
+
|
|
200
|
+
// A fresh module registry re-runs module initialisation, which is where the
|
|
201
|
+
// salt is minted — the closest thing in-process to a restarted node. A
|
|
202
|
+
// hard-coded or configured salt passes every other case in this block and
|
|
203
|
+
// fails here, which is the point: the salt is deliberately ephemeral, so
|
|
204
|
+
// nothing correlates a key across processes. `isolateModulesAsync` rather
|
|
205
|
+
// than a bare `resetModules`, so the isolation ends with this case and no
|
|
206
|
+
// later test in the file inherits a reset registry.
|
|
207
|
+
let afterRestart = '';
|
|
208
|
+
let stableAfterRestart = false;
|
|
209
|
+
await jest.isolateModulesAsync(async () => {
|
|
210
|
+
const reloaded = await import('../node/rateLimit');
|
|
211
|
+
afterRestart = reloaded.clientRateLimitKey({ ip: ADDRESS } as Request);
|
|
212
|
+
stableAfterRestart = reloaded.clientRateLimitKey({ ip: ADDRESS } as Request) === afterRestart;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(afterRestart).not.toBe(before);
|
|
216
|
+
// The reloaded instance is internally consistent too — a key that simply
|
|
217
|
+
// changed on every call would satisfy the line above while counting nothing.
|
|
218
|
+
expect(stableAfterRestart).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('answers the `unknown` sentinel when Express resolved no address', () => {
|
|
222
|
+
expect(clientRateLimitKey({} as Request)).toBe('unknown');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('is what the LIMITER keys on, not merely available beside it', () => {
|
|
226
|
+
// A hasher can be correct, exported, fully tested and never called — the
|
|
227
|
+
// revert this guards against is one line in the middleware body. So: drive a
|
|
228
|
+
// real request through the real limiter and require that the hash happened.
|
|
229
|
+
const crypto = require('node:crypto') as typeof import('node:crypto');
|
|
230
|
+
const hmac = jest.spyOn(crypto, 'createHmac');
|
|
231
|
+
const limiter = createRateLimiter({ windowMs: 60_000, max: 5 });
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
expect(call(limiter, '198.51.100.4').passed).toBe(true);
|
|
235
|
+
expect(hmac).toHaveBeenCalledTimes(1);
|
|
236
|
+
|
|
237
|
+
// CONTROL: the sentinel path hashes NOTHING, so the count above is this
|
|
238
|
+
// limiter's own work rather than a spy that reports a call no matter what
|
|
239
|
+
// the request was.
|
|
240
|
+
hmac.mockClear();
|
|
241
|
+
expect(call(limiter).passed).toBe(true);
|
|
242
|
+
expect(hmac).not.toHaveBeenCalled();
|
|
243
|
+
} finally {
|
|
244
|
+
limiter.stop();
|
|
245
|
+
hmac.mockRestore();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('stop() halts the sweep timer and is idempotent', () => {
|
|
251
|
+
jest.useFakeTimers();
|
|
252
|
+
const limiter = createRateLimiter({ windowMs: 1_000, max: 1 });
|
|
253
|
+
|
|
254
|
+
// Seed and exhaust a key, then stop the limiter.
|
|
255
|
+
expect(call(limiter, 'k').passed).toBe(true);
|
|
256
|
+
expect(call(limiter, 'k').passed).toBe(false);
|
|
257
|
+
limiter.stop();
|
|
258
|
+
limiter.stop(); // second call must not throw
|
|
259
|
+
|
|
260
|
+
// After stop, the active sweep no longer runs — the entry is NOT reclaimed by
|
|
261
|
+
// the timer. Advancing past the sweep interval leaves the key tracked; only
|
|
262
|
+
// the lazy expiry-on-access (its window having elapsed) gives it a fresh slot.
|
|
263
|
+
jest.advanceTimersByTime(10_000);
|
|
264
|
+
// The window has elapsed, so the next access resets the key lazily; this
|
|
265
|
+
// proves the limiter still functions while confirming stop() didn't crash.
|
|
266
|
+
expect(call(limiter, 'k').passed).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asserts that this suite is running under the runner it is configured for.
|
|
3
|
+
*
|
|
4
|
+
* @oxy.so/protocol is a JEST package: `bun run test` -> `jest` -> `jest.config.cjs`.
|
|
5
|
+
* Bun also ships its OWN test runner, one character away (`bun test`), which
|
|
6
|
+
* ignores `jest.config.cjs` entirely. The two disagree, and the disagreement is
|
|
7
|
+
* silent — bun's runner half-runs the suite and prints a plausible pass/fail
|
|
8
|
+
* count that has already been mistaken for a real regression.
|
|
9
|
+
*
|
|
10
|
+
* `bunfig.toml`'s `[test].preload` refuses `bun test` when it is run FROM this
|
|
11
|
+
* package directory, which is the common case. It cannot cover
|
|
12
|
+
* `bun test packages/protocol` from the repo root, because bun resolves
|
|
13
|
+
* `bunfig.toml` from the working directory and never walks up. This file is the
|
|
14
|
+
* second layer: it travels with the tests, so it fires wherever they are run
|
|
15
|
+
* from.
|
|
16
|
+
*
|
|
17
|
+
* Both halves of the config are checked, because both are load-bearing:
|
|
18
|
+
*
|
|
19
|
+
* - `moduleNameMapper` resolves `@oxy.so/contracts` from TypeScript SOURCE, so
|
|
20
|
+
* the tests never depend on that package being built first. Deleting it (or
|
|
21
|
+
* skipping it, as bun's runner does) breaks six suites on a clean checkout
|
|
22
|
+
* with `Cannot find module '@oxy.so/contracts'`.
|
|
23
|
+
* - The jest module registry (`resetModules` / `isolateModules` /
|
|
24
|
+
* `isolateModulesAsync` / `doMock`) is what `optionalNativePeers.test.ts`
|
|
25
|
+
* uses to simulate an optional peer that does not resolve AT ALL. bun 1.3.14
|
|
26
|
+
* provides `fn`/`spyOn`/`mock`/timers but none of the registry family, and
|
|
27
|
+
* its `mock.module` cannot express a virtual (unresolvable) module.
|
|
28
|
+
*
|
|
29
|
+
* The registry check runs at MODULE scope and exits the process rather than
|
|
30
|
+
* failing an assertion: a failed assertion would still let the run finish and
|
|
31
|
+
* print `134 pass / 4 fail`, which is the exact failure mode being guarded
|
|
32
|
+
* against. The predicate is the capability itself, not the runner's name, so a
|
|
33
|
+
* future bun that implements the registry stops tripping it on its own.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { resolve } from 'node:path';
|
|
37
|
+
|
|
38
|
+
/** The jest APIs this package's suites call. Absent ones cannot be shimmed. */
|
|
39
|
+
const REQUIRED_REGISTRY_APIS = [
|
|
40
|
+
'resetModules',
|
|
41
|
+
'isolateModules',
|
|
42
|
+
'isolateModulesAsync',
|
|
43
|
+
'doMock',
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
const registry = jest as unknown as Record<string, unknown>;
|
|
47
|
+
const missingRegistryApis = REQUIRED_REGISTRY_APIS.filter(
|
|
48
|
+
(name) => typeof registry[name] !== 'function',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (missingRegistryApis.length > 0) {
|
|
52
|
+
console.error(
|
|
53
|
+
[
|
|
54
|
+
'',
|
|
55
|
+
'@oxy.so/protocol was started with a runner that is not jest.',
|
|
56
|
+
'',
|
|
57
|
+
` Missing jest APIs this suite calls: ${missingRegistryApis.map((name) => `jest.${name}`).join(', ')}`,
|
|
58
|
+
'',
|
|
59
|
+
' Run bun run test (note the `run`) from packages/protocol — the',
|
|
60
|
+
' declared script, which invokes jest and honours jest.config.cjs.',
|
|
61
|
+
'',
|
|
62
|
+
" `bun test` uses bun's own runner, which ignores jest.config.cjs. It",
|
|
63
|
+
' does not fail cleanly: it half-runs the suite and prints a plausible',
|
|
64
|
+
' pass/fail count. Aborting so that count is never produced.',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('test runner configuration', () => {
|
|
72
|
+
it('resolves @oxy.so/contracts from source via moduleNameMapper', () => {
|
|
73
|
+
// Not a style preference: without the mapper the suites require
|
|
74
|
+
// packages/contracts/dist, which does not exist on a clean checkout.
|
|
75
|
+
expect(require.resolve('@oxy.so/contracts')).toBe(
|
|
76
|
+
resolve(__dirname, '..', '..', '..', 'contracts', 'src', 'index.ts'),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('exposes the jest module registry the optional-peer suite depends on', () => {
|
|
81
|
+
// Reached only when the module-scope guard above did not fire; keeps the
|
|
82
|
+
// requirement visible as an assertion rather than only as a process exit.
|
|
83
|
+
expect(missingRegistryApis).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveSecp256k1PublicKey,
|
|
3
|
+
deriveSecp256k1SharedSecret,
|
|
4
|
+
generateSecp256k1KeyPair,
|
|
5
|
+
isValidSecp256k1PrivateKey,
|
|
6
|
+
isValidSecp256k1PublicKey,
|
|
7
|
+
normalizeSecp256k1PrivateKey,
|
|
8
|
+
normalizeSecp256k1PublicKey,
|
|
9
|
+
signSecp256k1Digest,
|
|
10
|
+
verifySecp256k1Digest,
|
|
11
|
+
} from "../secp256k1";
|
|
12
|
+
|
|
13
|
+
const PRIVATE_KEY_ONE = `${"0".repeat(63)}1`;
|
|
14
|
+
const PRIVATE_KEY_TWO = `${"0".repeat(63)}2`;
|
|
15
|
+
const DIGEST_ONE = `${"0".repeat(63)}1`;
|
|
16
|
+
|
|
17
|
+
const PUBLIC_KEY_ONE =
|
|
18
|
+
"0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +
|
|
19
|
+
"483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
|
|
20
|
+
const COMPRESSED_PUBLIC_KEY_ONE =
|
|
21
|
+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
|
22
|
+
const PUBLIC_KEY_TWO =
|
|
23
|
+
"04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" +
|
|
24
|
+
"1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a";
|
|
25
|
+
|
|
26
|
+
// Exact DER output produced by elliptic 6.6.1 for this RFC 6979 vector.
|
|
27
|
+
const HISTORICAL_HIGH_S_SIGNATURE =
|
|
28
|
+
"304502206673ffad2147741f04772b6f921f0ba6af0c1e77fc439e65c36dedf4092e8898" +
|
|
29
|
+
"022100b3e568e9ad1f52577fedf107fda18f5ebb8e5c220badf23532bf6fbcc67fb4b8";
|
|
30
|
+
const LOW_S_SIGNATURE =
|
|
31
|
+
"304402206673ffad2147741f04772b6f921f0ba6af0c1e77fc439e65c36dedf4092e8898" +
|
|
32
|
+
"02204c1a971652e0ada880120ef8025e709fff2080c4a39aae068d12eed009b68c89";
|
|
33
|
+
|
|
34
|
+
describe("secp256k1 primitives", () => {
|
|
35
|
+
test("preserves the canonical private/public key formats", () => {
|
|
36
|
+
expect(normalizeSecp256k1PrivateKey("1")).toBe(PRIVATE_KEY_ONE);
|
|
37
|
+
expect(deriveSecp256k1PublicKey(PRIVATE_KEY_ONE)).toBe(PUBLIC_KEY_ONE);
|
|
38
|
+
expect(deriveSecp256k1PublicKey(PRIVATE_KEY_ONE, true)).toBe(
|
|
39
|
+
COMPRESSED_PUBLIC_KEY_ONE,
|
|
40
|
+
);
|
|
41
|
+
expect(normalizeSecp256k1PublicKey(COMPRESSED_PUBLIC_KEY_ONE)).toBe(
|
|
42
|
+
PUBLIC_KEY_ONE,
|
|
43
|
+
);
|
|
44
|
+
expect(normalizeSecp256k1PublicKey(PUBLIC_KEY_ONE, true)).toBe(
|
|
45
|
+
COMPRESSED_PUBLIC_KEY_ONE,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("matches the historical deterministic DER wire format", () => {
|
|
50
|
+
const signature = signSecp256k1Digest(PRIVATE_KEY_ONE, DIGEST_ONE);
|
|
51
|
+
expect(signature).toBe(HISTORICAL_HIGH_S_SIGNATURE);
|
|
52
|
+
expect(verifySecp256k1Digest(PUBLIC_KEY_ONE, DIGEST_ONE, signature)).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
expect(
|
|
56
|
+
verifySecp256k1Digest(COMPRESSED_PUBLIC_KEY_ONE, DIGEST_ONE, signature),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("supports explicit low-S signatures while accepting both historical forms", () => {
|
|
61
|
+
const signature = signSecp256k1Digest(PRIVATE_KEY_ONE, DIGEST_ONE, {
|
|
62
|
+
lowS: true,
|
|
63
|
+
});
|
|
64
|
+
expect(signature).toBe(LOW_S_SIGNATURE);
|
|
65
|
+
expect(verifySecp256k1Digest(PUBLIC_KEY_ONE, DIGEST_ONE, signature)).toBe(
|
|
66
|
+
true,
|
|
67
|
+
);
|
|
68
|
+
expect(
|
|
69
|
+
verifySecp256k1Digest(
|
|
70
|
+
PUBLIC_KEY_ONE,
|
|
71
|
+
DIGEST_ONE,
|
|
72
|
+
HISTORICAL_HIGH_S_SIGNATURE,
|
|
73
|
+
),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("derives the fixed-width ECDH x-coordinate symmetrically", () => {
|
|
78
|
+
const expected =
|
|
79
|
+
"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
|
80
|
+
const oneToTwo = deriveSecp256k1SharedSecret(
|
|
81
|
+
PRIVATE_KEY_ONE,
|
|
82
|
+
PUBLIC_KEY_TWO,
|
|
83
|
+
);
|
|
84
|
+
const twoToOne = deriveSecp256k1SharedSecret(
|
|
85
|
+
PRIVATE_KEY_TWO,
|
|
86
|
+
PUBLIC_KEY_ONE,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(Buffer.from(oneToTwo).toString("hex")).toBe(expected);
|
|
90
|
+
expect(oneToTwo).toEqual(twoToOne);
|
|
91
|
+
expect(oneToTwo).toHaveLength(32);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("generates canonical key pairs that round-trip", () => {
|
|
95
|
+
const keyPair = generateSecp256k1KeyPair();
|
|
96
|
+
expect(keyPair.privateKey).toMatch(/^[0-9a-f]{64}$/);
|
|
97
|
+
expect(keyPair.publicKey).toMatch(/^04[0-9a-f]{128}$/);
|
|
98
|
+
expect(deriveSecp256k1PublicKey(keyPair.privateKey)).toBe(
|
|
99
|
+
keyPair.publicKey,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("strictly rejects malformed scalars, points, digests, and DER", () => {
|
|
104
|
+
expect(isValidSecp256k1PrivateKey("0")).toBe(false);
|
|
105
|
+
expect(isValidSecp256k1PrivateKey("zz")).toBe(false);
|
|
106
|
+
expect(isValidSecp256k1PrivateKey("f".repeat(66))).toBe(false);
|
|
107
|
+
expect(isValidSecp256k1PublicKey(`04${"0".repeat(128)}`)).toBe(false);
|
|
108
|
+
expect(isValidSecp256k1PublicKey(`05${PUBLIC_KEY_ONE.slice(2)}`)).toBe(
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
expect(() => signSecp256k1Digest(PRIVATE_KEY_ONE, "00")).toThrow(
|
|
112
|
+
"digest must be exactly 32 bytes",
|
|
113
|
+
);
|
|
114
|
+
expect(() =>
|
|
115
|
+
verifySecp256k1Digest(PUBLIC_KEY_ONE, DIGEST_ONE, "00"),
|
|
116
|
+
).toThrow();
|
|
117
|
+
});
|
|
118
|
+
});
|