Skip to content

Commit 90e98e1

Browse files
authored
Merge pull request #172 from kjartan221/@bsv/auth-update
feat(auth): bind request payloads into auth proofs
2 parents 5c5b173 + cf5d8c6 commit 90e98e1

9 files changed

Lines changed: 377 additions & 43 deletions

File tree

packages/middleware/auth/jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ module.exports = {
33
testEnvironment: 'node',
44
roots: ['<rootDir>/src'],
55
testMatch: ['**/__tests__/**/*.test.ts'],
6+
// Perf benchmark is opt-in via `npm run test:perf`; keep it out of the default run.
7+
testPathIgnorePatterns: ['/node_modules/', String.raw`\.perf\.test\.ts$`],
68
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
79
moduleNameMapper: {
810
'^(\\.{1,2}/.*)\\.js$': '$1',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const base = require('./jest.config');
2+
3+
// Runs ONLY the perf benchmark (which the default `jest` run excludes).
4+
module.exports = {
5+
...base,
6+
testMatch: ['**/__tests__/**/*.perf.test.ts'],
7+
testPathIgnorePatterns: ['/node_modules/'],
8+
};

packages/middleware/auth/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bsv/auth",
3-
"version": "0.0.1",
3+
"version": "0.0.2",
44
"description": "Signed, expiry-bound, single-use wallet auth proofs for BSV apps.",
55
"main": "dist/index.js",
66
"module": "dist/index.mjs",
@@ -25,6 +25,7 @@
2525
"lint:ci-disabled": "ts-standard src/**/*.ts",
2626
"lint": "ts-standard --fix src/**/*.ts",
2727
"test:watch": "jest --watch",
28+
"test:perf": "jest --config jest.perf.config.js --runInBand",
2829
"test:coverage-disabled": "jest --coverage",
2930
"build": "tsup && tsc --emitDeclarationOnly"
3031
},
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { performance } from 'node:perf_hooks';
2+
import {
3+
createAuthProof,
4+
verifyAuthProof,
5+
serializeSignablePayload,
6+
normalizeBody,
7+
createAuthSigData,
8+
} from '../core.js';
9+
import { ProtoWallet, PrivateKey, type WalletProtocol } from '@bsv/sdk';
10+
11+
// Local micro-benchmark — NOT a pass/fail SLA. It logs avg/throughput and asserts
12+
// only very loose ceilings, to catch catastrophic regressions (e.g. an accidental
13+
// O(n) blow-up), never normal machine variance. Run with `npm run test:perf`; tune
14+
// the crypto iteration count with the PERF_ITER env var (default 50).
15+
const ITER = Math.max(1, Number(process.env.PERF_ITER ?? 50));
16+
const PURE_ITER = 20_000;
17+
const WARMUP = Math.min(5, ITER);
18+
19+
// Large window so pre-minted proofs never expire mid-run, even at a high PERF_ITER.
20+
const OPTIONS = { protocol: [2, 'perf auth'] as WalletProtocol, windowMs: 10 * 60 * 1000 };
21+
const BODY = { newUsername: 'alice', bio: 'x'.repeat(256) };
22+
23+
interface Stats { label: string; n: number; avgMs: number; minMs: number; maxMs: number; opsPerSec: number; }
24+
25+
function summarize(label: string, samples: number[]): Stats {
26+
const total = samples.reduce((a, b) => a + b, 0);
27+
const avgMs = total / samples.length;
28+
return { label, n: samples.length, avgMs, minMs: Math.min(...samples), maxMs: Math.max(...samples), opsPerSec: 1000 / avgMs };
29+
}
30+
31+
// Slow (crypto) ops: sample each call so we get a meaningful min/max/avg.
32+
async function benchAsync(label: string, n: number, fn: (i: number) => Promise<unknown>): Promise<Stats> {
33+
for (let i = 0; i < WARMUP; i++) await fn(i);
34+
const samples: number[] = [];
35+
for (let i = 0; i < n; i++) {
36+
const t0 = performance.now();
37+
await fn(i);
38+
samples.push(performance.now() - t0);
39+
}
40+
return summarize(label, samples);
41+
}
42+
43+
// Fast (pure) ops: time the whole batch — per-call timer overhead would dwarf them.
44+
function benchSyncBatch(label: string, n: number, fn: (i: number) => unknown): Stats {
45+
for (let i = 0; i < WARMUP; i++) fn(i);
46+
const t0 = performance.now();
47+
for (let i = 0; i < n; i++) fn(i);
48+
const avgMs = (performance.now() - t0) / n;
49+
return { label, n, avgMs, minMs: avgMs, maxMs: avgMs, opsPerSec: 1000 / avgMs };
50+
}
51+
52+
function report(s: Stats): void {
53+
const ops = Math.round(s.opsPerSec).toLocaleString();
54+
// eslint-disable-next-line no-console
55+
console.log(
56+
`[perf] ${s.label.padEnd(32)} ${String(s.n).padStart(6)}x ` +
57+
`avg ${s.avgMs.toFixed(4)}ms min ${s.minMs.toFixed(4)} max ${s.maxMs.toFixed(4)} ${ops} ops/s`,
58+
);
59+
}
60+
61+
describe('performance (local benchmark — `npm run test:perf`, PERF_ITER to scale)', () => {
62+
let clientWallet: ProtoWallet;
63+
let serverWallet: ProtoWallet;
64+
let serverKey: string;
65+
const consumeOk = (): boolean => true;
66+
67+
beforeAll(async () => {
68+
clientWallet = new ProtoWallet(PrivateKey.fromRandom());
69+
serverWallet = new ProtoWallet(PrivateKey.fromRandom());
70+
serverKey = (await serverWallet.getPublicKey({ identityKey: true })).publicKey;
71+
});
72+
73+
it(`createAuthProof — bodyless vs body-bound (${ITER}x)`, async () => {
74+
const login = await benchAsync('createAuthProof (login)', ITER, () =>
75+
createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'login', ...OPTIONS }));
76+
const body = await benchAsync('createAuthProof (body)', ITER, () =>
77+
createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'update', body: BODY, ...OPTIONS }));
78+
report(login);
79+
report(body);
80+
expect(login.avgMs).toBeLessThan(500);
81+
expect(body.avgMs).toBeLessThan(500);
82+
}, 120_000);
83+
84+
it(`verifyAuthProof — bodyless vs body-bound (${ITER}x)`, async () => {
85+
const loginProofs = await Promise.all(Array.from({ length: ITER }, async () =>
86+
createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'login', ...OPTIONS })));
87+
const bodyProofs = await Promise.all(Array.from({ length: ITER }, async () =>
88+
createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'update', body: BODY, ...OPTIONS })));
89+
90+
const vLogin = await benchAsync('verifyAuthProof (login)', ITER, (i) =>
91+
verifyAuthProof({ wallet: serverWallet, proof: loginProofs[i], action: 'login', consumeNonce: consumeOk, ...OPTIONS }));
92+
const vBody = await benchAsync('verifyAuthProof (body)', ITER, (i) =>
93+
verifyAuthProof({ wallet: serverWallet, proof: bodyProofs[i], action: 'update', consumeNonce: consumeOk, body: BODY, ...OPTIONS }));
94+
report(vLogin);
95+
report(vBody);
96+
expect(vLogin.avgMs).toBeLessThan(500);
97+
expect(vBody.avgMs).toBeLessThan(500);
98+
}, 120_000);
99+
100+
it(`pure serialization, no crypto (${PURE_ITER}x) — should dwarf the crypto path`, () => {
101+
const data = createAuthSigData('login', '02abc', OPTIONS);
102+
const ser = benchSyncBatch('serializeSignablePayload(body)', PURE_ITER, () => serializeSignablePayload(data, BODY));
103+
const norm = benchSyncBatch('normalizeBody(object)', PURE_ITER, () => normalizeBody(BODY));
104+
report(ser);
105+
report(norm);
106+
expect(ser.avgMs).toBeLessThan(1);
107+
expect(norm.avgMs).toBeLessThan(1);
108+
});
109+
});

packages/middleware/auth/src/__tests__/authProof.test.ts

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { AuthProofClient, AuthProofServer } from '../auth.js';
2-
import { serializeAuthSigData, createAuthSigData } from '../core.js';
2+
import {
3+
serializeAuthSigData,
4+
serializeSignablePayload,
5+
normalizeBody,
6+
createAuthSigData,
7+
createAuthProof,
8+
verifyAuthProof,
9+
} from '../core.js';
310
import type { AuthProof, AuthSigData } from '../types.js';
4-
import { Utils, type WalletProtocol } from '@bsv/sdk';
11+
import { Utils, ProtoWallet, PrivateKey, type WalletProtocol } from '@bsv/sdk';
512

613
const NOW = 1_700_000_000_000;
714
const WINDOW = 2 * 60 * 1000;
@@ -78,45 +85,150 @@ describe('AuthProofServer.verifyAuthProof', () => {
7885

7986
it('accepts a valid, fresh, unused proof and returns the identity key', async () => {
8087
const wallet = okWallet();
81-
const result = await server.verifyAuthProof(wallet, proof(), 'login', { now: NOW, consumeNonce: consumeOk });
88+
const result = await server.verifyAuthProof({ wallet, proof: proof(), action: 'login', now: NOW, consumeNonce: consumeOk });
8289
expect(result).toEqual({ valid: true, identityKey: IDENTITY });
8390
expect(wallet.verifySignature).toHaveBeenCalledTimes(1);
8491
});
8592

8693
it('rejects malformed / action mismatch / expired before the signature check', async () => {
8794
const wallet = okWallet();
88-
expect((await server.verifyAuthProof(wallet, { data: undefined, signature: [] } as unknown as AuthProof, 'login', { now: NOW, consumeNonce: consumeOk })).error).toBe('Malformed proof');
89-
expect((await server.verifyAuthProof(wallet, proof(), 'delete', { now: NOW, consumeNonce: consumeOk })).error).toBe('Action mismatch');
90-
expect((await server.verifyAuthProof(wallet, proof({ expiresAt: NOW - 1 }), 'login', { now: NOW, consumeNonce: consumeOk })).error).toBe('Proof expired');
95+
expect((await server.verifyAuthProof({ wallet, proof: { data: undefined, signature: [] } as unknown as AuthProof, action: 'login', now: NOW, consumeNonce: consumeOk })).error).toBe('Malformed proof');
96+
expect((await server.verifyAuthProof({ wallet, proof: proof(), action: 'delete', now: NOW, consumeNonce: consumeOk })).error).toBe('Action mismatch');
97+
expect((await server.verifyAuthProof({ wallet, proof: proof({ expiresAt: NOW - 1 }), action: 'login', now: NOW, consumeNonce: consumeOk })).error).toBe('Proof expired');
9198
expect(wallet.verifySignature).not.toHaveBeenCalled();
9299
});
93100

94101
it('rejects an invalid signature without consuming the nonce', async () => {
95102
const wallet = { verifySignature: jest.fn(async () => ({ valid: false })) };
96103
const consume = jest.fn(async () => true);
97-
const result = await server.verifyAuthProof(wallet, proof(), 'login', { now: NOW, consumeNonce: consume });
104+
const result = await server.verifyAuthProof({ wallet, proof: proof(), action: 'login', now: NOW, consumeNonce: consume });
98105
expect(result.error).toBe('Invalid signature');
99106
expect(consume).not.toHaveBeenCalled();
100107
});
101108

102109
it('treats a throwing verifySignature as invalid (e.g. malformed identityKey) and does not consume', async () => {
103110
const wallet = { verifySignature: jest.fn(async () => { throw new Error('bad public key'); }) };
104111
const consume = jest.fn(async () => true);
105-
const result = await server.verifyAuthProof(wallet, proof(), 'login', { now: NOW, consumeNonce: consume });
112+
const result = await server.verifyAuthProof({ wallet, proof: proof(), action: 'login', now: NOW, consumeNonce: consume });
106113
expect(result.error).toBe('Invalid signature');
107114
expect(consume).not.toHaveBeenCalled();
108115
});
109116

110117
it('rejects a replayed proof (nonce already consumed)', async () => {
111-
const result = await server.verifyAuthProof(okWallet(), proof(), 'login', { now: NOW, consumeNonce: () => false });
118+
const result = await server.verifyAuthProof({ wallet: okWallet(), proof: proof(), action: 'login', now: NOW, consumeNonce: () => false });
112119
expect(result.error).toBe('Proof already used');
113120
});
114121

115122
it('verifies the signature against the proof identity key and nonce', async () => {
116123
const wallet = okWallet();
117-
await server.verifyAuthProof(wallet, proof(), 'login', { now: NOW, consumeNonce: consumeOk });
124+
await server.verifyAuthProof({ wallet, proof: proof(), action: 'login', now: NOW, consumeNonce: consumeOk });
118125
expect(wallet.verifySignature).toHaveBeenCalledWith(
119126
expect.objectContaining({ counterparty: IDENTITY, keyID: 'cmFuZG9tbm9uY2U=' }),
120127
);
121128
});
122129
});
130+
131+
describe('normalizeBody', () => {
132+
it('UTF-8 encodes strings', () => {
133+
expect(normalizeBody('hi')).toEqual(Utils.toArray('hi', 'utf8'));
134+
});
135+
136+
it('reads ArrayBuffer and typed arrays as raw bytes', () => {
137+
const u8 = new Uint8Array([9, 8, 7]);
138+
expect(normalizeBody(u8)).toEqual([9, 8, 7]);
139+
expect(normalizeBody(u8.buffer)).toEqual([9, 8, 7]);
140+
});
141+
142+
it('respects a typed-array view offset and length', () => {
143+
const view = new Uint8Array(new Uint8Array([1, 2, 3, 4, 5]).buffer, 1, 3);
144+
expect(normalizeBody(view)).toEqual([2, 3, 4]);
145+
});
146+
147+
it('JSON-encodes plain objects', () => {
148+
expect(normalizeBody({ a: 1 })).toEqual(Utils.toArray('{"a":1}', 'utf8'));
149+
});
150+
151+
it('JSON-encodes arrays (params, objects, numbers) rather than treating them as bytes', () => {
152+
expect(normalizeBody(['a=1', 'b=2'])).toEqual(Utils.toArray('["a=1","b=2"]', 'utf8'));
153+
expect(normalizeBody([{ id: 1 }])).toEqual(Utils.toArray('[{"id":1}]', 'utf8'));
154+
expect(normalizeBody([1, 2, 3])).toEqual(Utils.toArray('[1,2,3]', 'utf8'));
155+
});
156+
});
157+
158+
describe('serializeSignablePayload (body binding)', () => {
159+
it('with no body equals serializeAuthSigData, so login proofs are unchanged', () => {
160+
expect(serializeSignablePayload(sigData())).toEqual(serializeAuthSigData(sigData()));
161+
});
162+
163+
it('binding a body changes the signed bytes and is body-sensitive', () => {
164+
const base = JSON.stringify(serializeSignablePayload(sigData(), { user: 'alice' }));
165+
expect(base).not.toBe(JSON.stringify(serializeAuthSigData(sigData())));
166+
expect(JSON.stringify(serializeSignablePayload(sigData(), { user: 'alice' }))).toBe(base);
167+
expect(JSON.stringify(serializeSignablePayload(sigData(), { user: 'bob' }))).not.toBe(base);
168+
});
169+
170+
it('distinguishes an empty body from no body', () => {
171+
expect(serializeSignablePayload(sigData(), '')).not.toEqual(serializeSignablePayload(sigData()));
172+
});
173+
174+
it('treats a JSON string and the equivalent object identically', () => {
175+
expect(serializeSignablePayload(sigData(), '{"user":"a"}'))
176+
.toEqual(serializeSignablePayload(sigData(), { user: 'a' }));
177+
});
178+
});
179+
180+
describe('round-trip with real wallets (createAuthProof → verifyAuthProof)', () => {
181+
const OPTS = OPTIONS;
182+
const consumeOk = () => true;
183+
let clientWallet: ProtoWallet;
184+
let serverWallet: ProtoWallet;
185+
let serverKey: string;
186+
let clientKey: string;
187+
188+
beforeAll(async () => {
189+
clientWallet = new ProtoWallet(PrivateKey.fromRandom());
190+
serverWallet = new ProtoWallet(PrivateKey.fromRandom());
191+
serverKey = (await serverWallet.getPublicKey({ identityKey: true })).publicKey;
192+
clientKey = (await clientWallet.getPublicKey({ identityKey: true })).publicKey;
193+
});
194+
195+
it('verifies a bodyless (login) proof', async () => {
196+
const p = await createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'login', ...OPTS });
197+
const r = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'login', consumeNonce: consumeOk, ...OPTS });
198+
expect(r).toEqual({ valid: true, identityKey: clientKey });
199+
});
200+
201+
it('verifies a body-bound request when given the same body', async () => {
202+
const body = { newUsername: 'alice' };
203+
const p = await createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'changeUsername', body, ...OPTS });
204+
const r = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'changeUsername', consumeNonce: consumeOk, body, ...OPTS });
205+
expect(r).toEqual({ valid: true, identityKey: clientKey });
206+
});
207+
208+
it('rejects a tampered body', async () => {
209+
const p = await createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'changeUsername', body: { newUsername: 'alice' }, ...OPTS });
210+
const r = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'changeUsername', consumeNonce: consumeOk, body: { newUsername: 'bob' }, ...OPTS });
211+
expect(r.valid).toBe(false);
212+
expect(r.error).toBe('Invalid signature');
213+
});
214+
215+
it('rejects a body-bound proof verified without the body', async () => {
216+
const p = await createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'changeUsername', body: { newUsername: 'alice' }, ...OPTS });
217+
const r = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'changeUsername', consumeNonce: consumeOk, ...OPTS });
218+
expect(r.valid).toBe(false);
219+
});
220+
221+
it('rejects a bodyless proof verified with an injected body', async () => {
222+
const p = await createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'login', ...OPTS });
223+
const r = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'login', consumeNonce: consumeOk, body: { x: 1 }, ...OPTS });
224+
expect(r.valid).toBe(false);
225+
});
226+
227+
it('binds a binary body (typed array) without corruption', async () => {
228+
const p = await createAuthProof({ wallet: clientWallet, counterparty: serverKey, action: 'upload', body: new Uint8Array([0, 255, 10, 13, 200, 1]), ...OPTS });
229+
const ok = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'upload', consumeNonce: consumeOk, body: new Uint8Array([0, 255, 10, 13, 200, 1]), ...OPTS });
230+
expect(ok.valid).toBe(true);
231+
const bad = await verifyAuthProof({ wallet: serverWallet, proof: p, action: 'upload', consumeNonce: consumeOk, body: new Uint8Array([0, 255, 10, 13, 200, 2]), ...OPTS });
232+
expect(bad.valid).toBe(false);
233+
});
234+
});

packages/middleware/auth/src/auth.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
ConsumeNonce,
1313
ProofSignerWallet,
1414
ProofVerifierWallet,
15+
RequestBody,
1516
VerifyAuthProofResult,
1617
} from './types.js';
1718

@@ -22,8 +23,10 @@ import type {
2223
export class AuthProofClient {
2324
constructor(private readonly options: AuthProofOptions = {}) {}
2425

25-
createAuthProof(wallet: ProofSignerWallet, counterparty: string, action: string): Promise<AuthProof> {
26-
return createAuthProof(wallet, counterparty, action, this.options);
26+
createAuthProof(
27+
args: { wallet: ProofSignerWallet; counterparty: string; action: string; body?: RequestBody },
28+
): Promise<AuthProof> {
29+
return createAuthProof({ ...args, ...this.options });
2730
}
2831

2932
createAuthSigData(action: string, identityKey: string, now?: number): AuthSigData {
@@ -44,12 +47,16 @@ export class AuthProofServer {
4447
constructor(private readonly options: AuthProofOptions = {}) {}
4548

4649
verifyAuthProof(
47-
wallet: ProofVerifierWallet,
48-
proof: AuthProof | undefined | null,
49-
expectedAction: string,
50-
deps: { consumeNonce: ConsumeNonce; now?: number },
50+
args: {
51+
wallet: ProofVerifierWallet;
52+
proof: AuthProof | undefined | null;
53+
action: string;
54+
consumeNonce: ConsumeNonce;
55+
now?: number;
56+
body?: RequestBody;
57+
},
5158
): Promise<VerifyAuthProofResult> {
52-
return verifyAuthProof(wallet, proof, expectedAction, deps, this.options);
59+
return verifyAuthProof({ ...args, ...this.options });
5360
}
5461

5562
checkAuthSigData(

0 commit comments

Comments
 (0)