@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.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/chain/continuity.js +54 -0
  5. package/dist/cjs/chain/engine.js +34 -0
  6. package/dist/cjs/chain/recordStore.js +25 -0
  7. package/dist/cjs/chain/types.js +22 -0
  8. package/dist/cjs/chain/verify.js +82 -0
  9. package/dist/cjs/envelope/canonicalJson.js +107 -0
  10. package/dist/cjs/envelope/recordId.js +60 -0
  11. package/dist/cjs/envelope/sign.js +75 -0
  12. package/dist/cjs/envelope/signingInput.js +32 -0
  13. package/dist/cjs/identity/resolver.js +50 -0
  14. package/dist/cjs/index.js +71 -0
  15. package/dist/cjs/node/constants.js +85 -0
  16. package/dist/cjs/node/didWebResolver.js +126 -0
  17. package/dist/cjs/node/httpFetch.js +61 -0
  18. package/dist/cjs/node/index.js +71 -0
  19. package/dist/cjs/node/nodeApp.js +344 -0
  20. package/dist/cjs/node/nodeClient.js +204 -0
  21. package/dist/cjs/node/rateLimit.js +187 -0
  22. package/dist/cjs/node/verifyRecord.js +51 -0
  23. package/dist/cjs/platform/crypto.js +186 -0
  24. package/dist/cjs/platform/crypto.native.js +204 -0
  25. package/dist/cjs/platform/expoTypes.js +24 -0
  26. package/dist/cjs/platform/platform.js +33 -0
  27. package/dist/cjs/secp256k1.js +148 -0
  28. package/dist/cjs/transparency/checkpoint.js +79 -0
  29. package/dist/cjs/transparency/tree.js +197 -0
  30. package/dist/esm/.tsbuildinfo +1 -0
  31. package/dist/esm/chain/continuity.js +51 -0
  32. package/dist/esm/chain/engine.js +31 -0
  33. package/dist/esm/chain/recordStore.js +24 -0
  34. package/dist/esm/chain/types.js +19 -0
  35. package/dist/esm/chain/verify.js +78 -0
  36. package/dist/esm/envelope/canonicalJson.js +104 -0
  37. package/dist/esm/envelope/recordId.js +56 -0
  38. package/dist/esm/envelope/sign.js +69 -0
  39. package/dist/esm/envelope/signingInput.js +29 -0
  40. package/dist/esm/identity/resolver.js +47 -0
  41. package/dist/esm/index.js +36 -0
  42. package/dist/esm/node/constants.js +82 -0
  43. package/dist/esm/node/didWebResolver.js +122 -0
  44. package/dist/esm/node/httpFetch.js +55 -0
  45. package/dist/esm/node/index.js +28 -0
  46. package/dist/esm/node/nodeApp.js +336 -0
  47. package/dist/esm/node/nodeClient.js +198 -0
  48. package/dist/esm/node/rateLimit.js +182 -0
  49. package/dist/esm/node/verifyRecord.js +48 -0
  50. package/dist/esm/platform/crypto.js +145 -0
  51. package/dist/esm/platform/crypto.native.js +196 -0
  52. package/dist/esm/platform/expoTypes.js +23 -0
  53. package/dist/esm/platform/platform.js +29 -0
  54. package/dist/esm/secp256k1.js +137 -0
  55. package/dist/esm/transparency/checkpoint.js +73 -0
  56. package/dist/esm/transparency/tree.js +189 -0
  57. package/dist/types/.tsbuildinfo +1 -0
  58. package/dist/types/chain/continuity.d.ts +28 -0
  59. package/dist/types/chain/engine.d.ts +27 -0
  60. package/dist/types/chain/recordStore.d.ts +85 -0
  61. package/dist/types/chain/types.d.ts +79 -0
  62. package/dist/types/chain/verify.d.ts +45 -0
  63. package/dist/types/envelope/canonicalJson.d.ts +44 -0
  64. package/dist/types/envelope/recordId.d.ts +30 -0
  65. package/dist/types/envelope/sign.d.ts +47 -0
  66. package/dist/types/envelope/signingInput.d.ts +33 -0
  67. package/dist/types/identity/resolver.d.ts +67 -0
  68. package/dist/types/index.d.ts +32 -0
  69. package/dist/types/node/constants.d.ts +80 -0
  70. package/dist/types/node/didWebResolver.d.ts +47 -0
  71. package/dist/types/node/httpFetch.d.ts +60 -0
  72. package/dist/types/node/index.d.ts +28 -0
  73. package/dist/types/node/nodeApp.d.ts +120 -0
  74. package/dist/types/node/nodeClient.d.ts +135 -0
  75. package/dist/types/node/rateLimit.d.ts +95 -0
  76. package/dist/types/node/verifyRecord.d.ts +41 -0
  77. package/dist/types/platform/crypto.d.ts +93 -0
  78. package/dist/types/platform/crypto.native.d.ts +77 -0
  79. package/dist/types/platform/expoTypes.d.ts +99 -0
  80. package/dist/types/platform/platform.d.ts +25 -0
  81. package/dist/types/secp256k1.d.ts +45 -0
  82. package/dist/types/transparency/checkpoint.d.ts +71 -0
  83. package/dist/types/transparency/tree.d.ts +135 -0
  84. package/package.json +157 -0
  85. package/src/__tests__/canonicalJson.test.ts +116 -0
  86. package/src/__tests__/chain.test.ts +279 -0
  87. package/src/__tests__/didWebResolver.test.ts +132 -0
  88. package/src/__tests__/envelope.test.ts +267 -0
  89. package/src/__tests__/nodeApp.test.ts +410 -0
  90. package/src/__tests__/nodeClient.test.ts +177 -0
  91. package/src/__tests__/nodeHarness.ts +151 -0
  92. package/src/__tests__/optionalNativePeers.test.ts +233 -0
  93. package/src/__tests__/rateLimit.test.ts +268 -0
  94. package/src/__tests__/runnerGuard.test.ts +85 -0
  95. package/src/__tests__/secp256k1.test.ts +118 -0
  96. package/src/__tests__/transparency.test.ts +353 -0
  97. package/src/chain/continuity.ts +59 -0
  98. package/src/chain/engine.ts +43 -0
  99. package/src/chain/recordStore.ts +98 -0
  100. package/src/chain/types.ts +85 -0
  101. package/src/chain/verify.ts +102 -0
  102. package/src/envelope/canonicalJson.ts +120 -0
  103. package/src/envelope/recordId.ts +63 -0
  104. package/src/envelope/sign.ts +86 -0
  105. package/src/envelope/signingInput.ts +48 -0
  106. package/src/identity/resolver.ts +90 -0
  107. package/src/index.ts +101 -0
  108. package/src/node/constants.ts +105 -0
  109. package/src/node/didWebResolver.ts +162 -0
  110. package/src/node/httpFetch.ts +88 -0
  111. package/src/node/index.ts +87 -0
  112. package/src/node/nodeApp.ts +471 -0
  113. package/src/node/nodeClient.ts +322 -0
  114. package/src/node/rateLimit.ts +233 -0
  115. package/src/node/verifyRecord.ts +60 -0
  116. package/src/platform/crypto.native.ts +251 -0
  117. package/src/platform/crypto.ts +172 -0
  118. package/src/platform/expoTypes.ts +99 -0
  119. package/src/platform/platform.ts +31 -0
  120. package/src/secp256k1.ts +207 -0
  121. package/src/transparency/checkpoint.ts +109 -0
  122. 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
+ });