|
1 | 1 | 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'; |
3 | 10 | 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'; |
5 | 12 |
|
6 | 13 | const NOW = 1_700_000_000_000; |
7 | 14 | const WINDOW = 2 * 60 * 1000; |
@@ -78,45 +85,150 @@ describe('AuthProofServer.verifyAuthProof', () => { |
78 | 85 |
|
79 | 86 | it('accepts a valid, fresh, unused proof and returns the identity key', async () => { |
80 | 87 | 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 }); |
82 | 89 | expect(result).toEqual({ valid: true, identityKey: IDENTITY }); |
83 | 90 | expect(wallet.verifySignature).toHaveBeenCalledTimes(1); |
84 | 91 | }); |
85 | 92 |
|
86 | 93 | it('rejects malformed / action mismatch / expired before the signature check', async () => { |
87 | 94 | 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'); |
91 | 98 | expect(wallet.verifySignature).not.toHaveBeenCalled(); |
92 | 99 | }); |
93 | 100 |
|
94 | 101 | it('rejects an invalid signature without consuming the nonce', async () => { |
95 | 102 | const wallet = { verifySignature: jest.fn(async () => ({ valid: false })) }; |
96 | 103 | 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 }); |
98 | 105 | expect(result.error).toBe('Invalid signature'); |
99 | 106 | expect(consume).not.toHaveBeenCalled(); |
100 | 107 | }); |
101 | 108 |
|
102 | 109 | it('treats a throwing verifySignature as invalid (e.g. malformed identityKey) and does not consume', async () => { |
103 | 110 | const wallet = { verifySignature: jest.fn(async () => { throw new Error('bad public key'); }) }; |
104 | 111 | 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 }); |
106 | 113 | expect(result.error).toBe('Invalid signature'); |
107 | 114 | expect(consume).not.toHaveBeenCalled(); |
108 | 115 | }); |
109 | 116 |
|
110 | 117 | 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 }); |
112 | 119 | expect(result.error).toBe('Proof already used'); |
113 | 120 | }); |
114 | 121 |
|
115 | 122 | it('verifies the signature against the proof identity key and nonce', async () => { |
116 | 123 | 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 }); |
118 | 125 | expect(wallet.verifySignature).toHaveBeenCalledWith( |
119 | 126 | expect.objectContaining({ counterparty: IDENTITY, keyID: 'cmFuZG9tbm9uY2U=' }), |
120 | 127 | ); |
121 | 128 | }); |
122 | 129 | }); |
| 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 | +}); |
0 commit comments