walletpair-sdk 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 +21 -0
- package/README.md +415 -0
- package/dist/ble/framing.d.ts +23 -0
- package/dist/ble/framing.d.ts.map +1 -0
- package/dist/ble/framing.js +83 -0
- package/dist/ble/framing.js.map +1 -0
- package/dist/ble/index.d.ts +9 -0
- package/dist/ble/index.d.ts.map +1 -0
- package/dist/ble/index.js +9 -0
- package/dist/ble/index.js.map +1 -0
- package/dist/ble/web-ble-transport.d.ts +29 -0
- package/dist/ble/web-ble-transport.d.ts.map +1 -0
- package/dist/ble/web-ble-transport.js +93 -0
- package/dist/ble/web-ble-transport.js.map +1 -0
- package/dist/crypto.d.ts +102 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +279 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dapp-session.d.ts +106 -0
- package/dist/dapp-session.d.ts.map +1 -0
- package/dist/dapp-session.js +918 -0
- package/dist/dapp-session.js.map +1 -0
- package/dist/emitter.d.ts +16 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evm/eip1193.d.ts +83 -0
- package/dist/evm/eip1193.d.ts.map +1 -0
- package/dist/evm/eip1193.js +270 -0
- package/dist/evm/eip1193.js.map +1 -0
- package/dist/evm/index.d.ts +8 -0
- package/dist/evm/index.d.ts.map +1 -0
- package/dist/evm/index.js +8 -0
- package/dist/evm/index.js.map +1 -0
- package/dist/evm/wagmi.d.ts +118 -0
- package/dist/evm/wagmi.d.ts.map +1 -0
- package/dist/evm/wagmi.js +205 -0
- package/dist/evm/wagmi.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +31 -0
- package/dist/types.js.map +1 -0
- package/dist/wallet-session.d.ts +107 -0
- package/dist/wallet-session.d.ts.map +1 -0
- package/dist/wallet-session.js +794 -0
- package/dist/wallet-session.js.map +1 -0
- package/dist/ws-transport.d.ts +29 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +79 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +55 -0
- package/src/__tests__/adversarial/crypto-attacks.test.ts +557 -0
- package/src/__tests__/adversarial/malicious-dapp.test.ts +505 -0
- package/src/__tests__/adversarial/malicious-relay.test.ts +528 -0
- package/src/__tests__/adversarial/malicious-wallet.test.ts +467 -0
- package/src/__tests__/spec-compliance/canonical-json.test.ts +227 -0
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +321 -0
- package/src/__tests__/spec-compliance/message-format.test.ts +356 -0
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +300 -0
- package/src/__tests__/spec-compliance/state-machine.test.ts +364 -0
- package/src/ble/framing.test.ts +196 -0
- package/src/ble/framing.ts +100 -0
- package/src/ble/index.ts +18 -0
- package/src/ble/web-ble-transport.test.ts +192 -0
- package/src/ble/web-ble-transport.ts +116 -0
- package/src/ble/web-bluetooth.d.ts +47 -0
- package/src/canonical-json.test.ts +612 -0
- package/src/crypto-directional.test.ts +263 -0
- package/src/crypto-hardening.test.ts +529 -0
- package/src/crypto.test.ts +635 -0
- package/src/crypto.ts +405 -0
- package/src/dapp-session.test.ts +647 -0
- package/src/dapp-session.ts +1004 -0
- package/src/emitter.test.ts +169 -0
- package/src/emitter.ts +45 -0
- package/src/evm/eip1193.test.ts +365 -0
- package/src/evm/eip1193.ts +346 -0
- package/src/evm/index.ts +19 -0
- package/src/evm/wagmi.test.ts +396 -0
- package/src/evm/wagmi.ts +321 -0
- package/src/index.ts +86 -0
- package/src/integration.test.ts +385 -0
- package/src/security.test.ts +430 -0
- package/src/sequence-validation.test.ts +1185 -0
- package/src/test-helpers.ts +216 -0
- package/src/types.test.ts +82 -0
- package/src/types.ts +305 -0
- package/src/wallet-session.test.ts +683 -0
- package/src/wallet-session.ts +922 -0
- package/src/ws-transport.test.ts +231 -0
- package/src/ws-transport.ts +92 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalletPair Protocol v1 — Appendix A cryptographic test vectors.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the SDK's crypto primitives produce byte-for-byte identical
|
|
5
|
+
* outputs for all known-input vectors in the specification. Any independent
|
|
6
|
+
* implementation can run these tests to confirm interoperability.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
b64urlDecode,
|
|
12
|
+
b64urlEncode,
|
|
13
|
+
bytesToHex,
|
|
14
|
+
canonicalJson,
|
|
15
|
+
computeHandshakeTranscriptHash,
|
|
16
|
+
computeSessionFingerprint,
|
|
17
|
+
computeSharedSecret,
|
|
18
|
+
deriveDirectionalSessionKeys,
|
|
19
|
+
deriveJoinEncryptionKey,
|
|
20
|
+
deriveSessionKey,
|
|
21
|
+
getPublicKey,
|
|
22
|
+
hexToBytes,
|
|
23
|
+
sealPayload,
|
|
24
|
+
sha256Hex,
|
|
25
|
+
unsealJoin,
|
|
26
|
+
unsealPayload,
|
|
27
|
+
} from '../../crypto.js';
|
|
28
|
+
import type { SessionCryptoContext } from '../../crypto.js';
|
|
29
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
30
|
+
import { hmac } from '@noble/hashes/hmac';
|
|
31
|
+
import { concatBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Appendix A values (all from the protocol spec)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const DAPP_PRIVATE_KEY = 'a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4';
|
|
38
|
+
const DAPP_PUBLIC_KEY = '1c9fd88f45606d932a80c71824ae151d15d73e77de38e8e000852e614fae7019';
|
|
39
|
+
const DAPP_PUB_B64 = 'HJ_Yj0VgbZMqgMcYJK4VHRXXPnfeOOjgAIUuYU-ucBk';
|
|
40
|
+
|
|
41
|
+
const WALLET_PRIVATE_KEY = '4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d';
|
|
42
|
+
const WALLET_PUBLIC_KEY = 'ff63fe57bfbf43fa3f563628b149af704d3db625369c49983650347a6a71e00e';
|
|
43
|
+
const WALLET_PUB_B64 = '_2P-V7-_Q_o_VjYosUmvcE09tiU2nEmYNlA0empx4A4';
|
|
44
|
+
|
|
45
|
+
const CHANNEL_ID = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2';
|
|
46
|
+
const SHARED_SECRET = '739311d35d8d3c41da4062c799a6c748808a31343facaaa7aa7e311908c1846e';
|
|
47
|
+
|
|
48
|
+
// A.2
|
|
49
|
+
const ROOT_KEY = 'c33b664ab3eea368d81109b432f04a1293a743212749e19bfe412a2996dcefee';
|
|
50
|
+
const JOIN_ENCRYPTION_KEY = '981e75c4fad86e3db377517816a24b27564661ab89d327217684e0a56d68ec11';
|
|
51
|
+
|
|
52
|
+
// A.4
|
|
53
|
+
const TRANSCRIPT_HASH = 'dd2cf890c3ac3855c1fb2479ada829d7dd3656001f80e316fa5e16fed5d6b535';
|
|
54
|
+
const DAPP_TO_WALLET_KEY = '1353e42d494f8618e6bfc04c0236cc6004994c52c95d371f113459ea153c7fdc';
|
|
55
|
+
const WALLET_TO_DAPP_KEY = 'fd0240cd5d4b00b3709549e102a918cf08d0d268fbdf468477cdc1ef663a55d6';
|
|
56
|
+
|
|
57
|
+
// A.5
|
|
58
|
+
const FINGERPRINT_SHA256 = '7f301a56626650b08f11c99df3333237a66fae34e0c0d1512c19fe51d41a8604';
|
|
59
|
+
const FINGERPRINT = '8902';
|
|
60
|
+
|
|
61
|
+
// A.6
|
|
62
|
+
const TRAFFIC_KEY_A6 = '1353e42d494f8618e6bfc04c0236cc6004994c52c95d371f113459ea153c7fdc';
|
|
63
|
+
const NONCE_A6 = 'b71ba8a87d41562e5af426d7';
|
|
64
|
+
const AAD_HEADER_A6 = '01002b484a5f596a305667625a4d71674d63594a4b345648525858506e66654f4f6a674149557559552d7563426b00077265712d303031';
|
|
65
|
+
const PLAINTEXT_A6 = '{"_method":"wallet_getAccounts","chain":"eip155:1"}';
|
|
66
|
+
const CIPHERTEXT_TAG_A6 =
|
|
67
|
+
'2aed1e76963c25234d9a2e023fdf40d35b9e1c7a9a3fd121' +
|
|
68
|
+
'c045c14df5e5627726213febe2459ff4c24d2c709d4c19d0' +
|
|
69
|
+
'0b6f43f8ea2418e68e8e0840bf7771ada851a5';
|
|
70
|
+
const SEALED_A6 = 'AAAAACrtHnaWPCUjTZouAj_fQNNbnhx6mj_RIcBFwU315WJ3JiE_6-JFn_TCTSxwnUwZ0AtvQ_jqJBjmjo4IQL93ca2oUaU';
|
|
71
|
+
|
|
72
|
+
// A.3
|
|
73
|
+
const JOIN_NONCE = '09474eabe263432ebc7e4756';
|
|
74
|
+
const JOIN_AAD = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b204';
|
|
75
|
+
const SEALED_JOIN_B64 = 'CUdOq-JjQy68fkdWHyGJa2GLNBPXEb-0vK1HlTCmQrZwEdqRRb0iyBX9ZHGkv74L6J24LMM2AVFsrXV9pjcCmtLx21fkj6ves8rd-RtjyW6WS44-qo87sn36IpLjhItuUjq_elDUr_qCOpwhoVIrFC29b7n_9q8UdbMAd5HwdKNiKFLrb91_rWhVj3H_y78fyft8LiHb52p0yF2RWB5m0-vZh0A9Rk9HBL9amsEPnOQiylZvCu-1gEko2SyCpkUGl0eXGOLs6vvnSCFiZjy8HLg95kjZoGaBqONQrF-dKoo-rT9hlW9fkMEi7rpDRzKWsTHIkYfnyTYNDk6M3o9mg_7z2k6FGwJqAXD2qCqjDXECVgytsvl_y68vSQqML2H74oFK_dx4SzHsoZWebv9fBve4kJHE5NEzCx3f';
|
|
76
|
+
|
|
77
|
+
const JOIN_PLAINTEXT = '{"capabilities":{"chains":["eip155:1","eip155:137"],"events":["accountsChanged","chainChanged"],"methods":["wallet_signTransaction","wallet_signMessage"]},"meta":{"description":"A multi-chain wallet","icon":"https://mywallet.app/icon.png","name":"MyWallet","url":"https://mywallet.app"}}';
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// A.1 Key Material
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe('Appendix A.1 — Key Material', () => {
|
|
84
|
+
it('derives the correct public key from the dApp private key', () => {
|
|
85
|
+
const pub = getPublicKey(hexToBytes(DAPP_PRIVATE_KEY));
|
|
86
|
+
expect(bytesToHex(pub)).toBe(DAPP_PUBLIC_KEY);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('derives the correct public key from the wallet private key', () => {
|
|
90
|
+
const pub = getPublicKey(hexToBytes(WALLET_PRIVATE_KEY));
|
|
91
|
+
expect(bytesToHex(pub)).toBe(WALLET_PUBLIC_KEY);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('base64url-encodes the dApp public key correctly', () => {
|
|
95
|
+
expect(b64urlEncode(hexToBytes(DAPP_PUBLIC_KEY))).toBe(DAPP_PUB_B64);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('base64url-encodes the wallet public key correctly', () => {
|
|
99
|
+
expect(b64urlEncode(hexToBytes(WALLET_PUBLIC_KEY))).toBe(WALLET_PUB_B64);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('computes the correct X25519 shared secret (dApp perspective)', () => {
|
|
103
|
+
const shared = computeSharedSecret(
|
|
104
|
+
hexToBytes(DAPP_PRIVATE_KEY),
|
|
105
|
+
hexToBytes(WALLET_PUBLIC_KEY),
|
|
106
|
+
);
|
|
107
|
+
expect(bytesToHex(shared)).toBe(SHARED_SECRET);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('computes the correct X25519 shared secret (wallet perspective)', () => {
|
|
111
|
+
const shared = computeSharedSecret(
|
|
112
|
+
hexToBytes(WALLET_PRIVATE_KEY),
|
|
113
|
+
hexToBytes(DAPP_PUBLIC_KEY),
|
|
114
|
+
);
|
|
115
|
+
expect(bytesToHex(shared)).toBe(SHARED_SECRET);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// A.2 Key Derivation
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
describe('Appendix A.2 — Key Derivation', () => {
|
|
124
|
+
it('derives the correct root_key', () => {
|
|
125
|
+
const rootKey = deriveSessionKey(hexToBytes(SHARED_SECRET), CHANNEL_ID);
|
|
126
|
+
expect(bytesToHex(rootKey)).toBe(ROOT_KEY);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('derives the correct join_encryption_key', () => {
|
|
130
|
+
const joinKey = deriveJoinEncryptionKey(hexToBytes(ROOT_KEY), CHANNEL_ID);
|
|
131
|
+
expect(bytesToHex(joinKey)).toBe(JOIN_ENCRYPTION_KEY);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// A.3 Sealed Join
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('Appendix A.3 — Sealed Join', () => {
|
|
140
|
+
it('canonical JSON of join plaintext matches the spec', () => {
|
|
141
|
+
const joinObj = {
|
|
142
|
+
capabilities: {
|
|
143
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
144
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
145
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
146
|
+
},
|
|
147
|
+
meta: {
|
|
148
|
+
name: 'MyWallet',
|
|
149
|
+
description: 'A multi-chain wallet',
|
|
150
|
+
url: 'https://mywallet.app',
|
|
151
|
+
icon: 'https://mywallet.app/icon.png',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
expect(canonicalJson(joinObj)).toBe(JOIN_PLAINTEXT);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('join_aad is channel_id_bytes || 0x04', () => {
|
|
158
|
+
const expected = hexToBytes(JOIN_AAD);
|
|
159
|
+
const chBytes = hexToBytes(CHANNEL_ID);
|
|
160
|
+
const aad = new Uint8Array(chBytes.length + 1);
|
|
161
|
+
aad.set(chBytes);
|
|
162
|
+
aad[chBytes.length] = 0x04;
|
|
163
|
+
expect(bytesToHex(aad)).toBe(bytesToHex(expected));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('decrypts the sealed_join test vector correctly', () => {
|
|
167
|
+
const joinKey = hexToBytes(JOIN_ENCRYPTION_KEY);
|
|
168
|
+
const result = unsealJoin(joinKey, CHANNEL_ID, SEALED_JOIN_B64);
|
|
169
|
+
expect(result.capabilities).toEqual({
|
|
170
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
171
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
172
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
173
|
+
});
|
|
174
|
+
expect(result.meta).toEqual({
|
|
175
|
+
description: 'A multi-chain wallet',
|
|
176
|
+
icon: 'https://mywallet.app/icon.png',
|
|
177
|
+
name: 'MyWallet',
|
|
178
|
+
url: 'https://mywallet.app',
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('sealed_join envelope starts with the specified nonce', () => {
|
|
183
|
+
const envelope = b64urlDecode(SEALED_JOIN_B64);
|
|
184
|
+
const nonce = envelope.slice(0, 12);
|
|
185
|
+
expect(bytesToHex(nonce)).toBe(JOIN_NONCE);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// A.4 Transcript and Traffic Keys
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('Appendix A.4 — Transcript Hash and Traffic Keys', () => {
|
|
194
|
+
const context: SessionCryptoContext = {
|
|
195
|
+
dappPubKeyB64: DAPP_PUB_B64,
|
|
196
|
+
walletPubKeyB64: WALLET_PUB_B64,
|
|
197
|
+
capabilities: {
|
|
198
|
+
chains: ['eip155:1', 'eip155:137'],
|
|
199
|
+
events: ['accountsChanged', 'chainChanged'],
|
|
200
|
+
methods: ['wallet_signTransaction', 'wallet_signMessage'],
|
|
201
|
+
},
|
|
202
|
+
walletMeta: {
|
|
203
|
+
description: 'A multi-chain wallet',
|
|
204
|
+
icon: 'https://mywallet.app/icon.png',
|
|
205
|
+
name: 'MyWallet',
|
|
206
|
+
url: 'https://mywallet.app',
|
|
207
|
+
},
|
|
208
|
+
dappName: 'MyDApp',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
it('computes the correct transcript_hash', () => {
|
|
212
|
+
const hash = computeHandshakeTranscriptHash(CHANNEL_ID, context);
|
|
213
|
+
expect(bytesToHex(hash)).toBe(TRANSCRIPT_HASH);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('derives the correct dapp_to_wallet_key', () => {
|
|
217
|
+
const rootKey = hexToBytes(ROOT_KEY);
|
|
218
|
+
const keys = deriveDirectionalSessionKeys(rootKey, CHANNEL_ID, context);
|
|
219
|
+
expect(bytesToHex(keys.dappToWalletKey)).toBe(DAPP_TO_WALLET_KEY);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('derives the correct wallet_to_dapp_key', () => {
|
|
223
|
+
const rootKey = hexToBytes(ROOT_KEY);
|
|
224
|
+
const keys = deriveDirectionalSessionKeys(rootKey, CHANNEL_ID, context);
|
|
225
|
+
expect(bytesToHex(keys.walletToDappKey)).toBe(WALLET_TO_DAPP_KEY);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('transcript_hash in directional keys matches standalone computation', () => {
|
|
229
|
+
const rootKey = hexToBytes(ROOT_KEY);
|
|
230
|
+
const keys = deriveDirectionalSessionKeys(rootKey, CHANNEL_ID, context);
|
|
231
|
+
expect(bytesToHex(keys.transcriptHash)).toBe(TRANSCRIPT_HASH);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// A.5 Session Fingerprint
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
describe('Appendix A.5 — Session Fingerprint', () => {
|
|
240
|
+
it('computes the correct SHA-256 prefix', () => {
|
|
241
|
+
// Verify the full SHA-256 matches
|
|
242
|
+
const hash = sha256(concatBytes(
|
|
243
|
+
utf8ToBytes('walletpair-v1-session-fingerprint'),
|
|
244
|
+
hexToBytes(CHANNEL_ID),
|
|
245
|
+
hexToBytes(DAPP_PUBLIC_KEY),
|
|
246
|
+
));
|
|
247
|
+
expect(bytesToHex(hash)).toBe(FINGERPRINT_SHA256);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('computes the correct 4-digit fingerprint', () => {
|
|
251
|
+
const fp = computeSessionFingerprint(CHANNEL_ID, DAPP_PUB_B64);
|
|
252
|
+
expect(fp).toBe(FINGERPRINT);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('fp_uint32 mod 10000 produces the correct value', () => {
|
|
256
|
+
// fp_bytes = 7f301a56, fp_uint32 = 2133858902
|
|
257
|
+
const fpBytes = hexToBytes('7f301a56');
|
|
258
|
+
const view = new DataView(fpBytes.buffer, fpBytes.byteOffset, 4);
|
|
259
|
+
const fpUint32 = view.getUint32(0);
|
|
260
|
+
expect(fpUint32).toBe(2133858902);
|
|
261
|
+
expect(fpUint32 % 10000).toBe(8902);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// A.6 AEAD Encryption (dapp->wallet, seq=0)
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe('Appendix A.6 — AEAD Encryption (dapp->wallet, seq=0)', () => {
|
|
270
|
+
it('nonce = HMAC-SHA256(traffic_key, seq_bytes)[0:12] matches the spec', () => {
|
|
271
|
+
const key = hexToBytes(TRAFFIC_KEY_A6);
|
|
272
|
+
const seqBytes = new Uint8Array(4); // seq=0
|
|
273
|
+
const nonce = hmac(sha256, key, seqBytes).slice(0, 12);
|
|
274
|
+
expect(bytesToHex(nonce)).toBe(NONCE_A6);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('AAD header matches the spec', () => {
|
|
278
|
+
// Rebuild AAD header: 0x01 || lp(from) || lp(id)
|
|
279
|
+
const fromStr = 'HJ_Yj0VgbZMqgMcYJK4VHRXXPnfeOOjgAIUuYU-ucBk';
|
|
280
|
+
const idStr = 'req-001';
|
|
281
|
+
|
|
282
|
+
function lp(s: string): Uint8Array {
|
|
283
|
+
const bytes = utf8ToBytes(s);
|
|
284
|
+
const len = new Uint8Array(2);
|
|
285
|
+
new DataView(len.buffer).setUint16(0, bytes.length);
|
|
286
|
+
return concatBytes(len, bytes);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const header = concatBytes(
|
|
290
|
+
new Uint8Array([0x01]),
|
|
291
|
+
lp(fromStr),
|
|
292
|
+
lp(idStr),
|
|
293
|
+
);
|
|
294
|
+
expect(bytesToHex(header)).toBe(AAD_HEADER_A6);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('sealPayload produces the expected sealed output for seq=0', () => {
|
|
298
|
+
const key = hexToBytes(TRAFFIC_KEY_A6);
|
|
299
|
+
const data = { _method: 'wallet_getAccounts', chain: 'eip155:1' };
|
|
300
|
+
const header = { type: 'req' as const, from: DAPP_PUB_B64, id: 'req-001' };
|
|
301
|
+
const sealed = sealPayload(key, CHANNEL_ID, 0, data, header);
|
|
302
|
+
expect(sealed).toBe(SEALED_A6);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('unsealPayload decrypts the expected sealed output for seq=0', () => {
|
|
306
|
+
const key = hexToBytes(TRAFFIC_KEY_A6);
|
|
307
|
+
const header = { type: 'req' as const, from: DAPP_PUB_B64, id: 'req-001' };
|
|
308
|
+
const { seq, data, plaintextJson } = unsealPayload(key, CHANNEL_ID, SEALED_A6, header);
|
|
309
|
+
expect(seq).toBe(0);
|
|
310
|
+
expect(plaintextJson).toBe(PLAINTEXT_A6);
|
|
311
|
+
expect(data).toEqual({ _method: 'wallet_getAccounts', chain: 'eip155:1' });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('ciphertext+tag in the sealed envelope matches the spec', () => {
|
|
315
|
+
const envelope = b64urlDecode(SEALED_A6);
|
|
316
|
+
const seqBytes = envelope.slice(0, 4);
|
|
317
|
+
const ciphertextTag = envelope.slice(4);
|
|
318
|
+
expect(bytesToHex(seqBytes)).toBe('00000000');
|
|
319
|
+
expect(bytesToHex(ciphertextTag)).toBe(CIPHERTEXT_TAG_A6);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalletPair Protocol v1 — Message envelope and body schema validation.
|
|
3
|
+
*
|
|
4
|
+
* Verifies Sections 4.1, 4.2, 3, and 15 rule 10 for message format
|
|
5
|
+
* compliance. Tests that message structures conform to the spec and
|
|
6
|
+
* that invalid messages are properly detected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { generateChannelId, b64urlEncode, generateX25519KeyPair } from '../../crypto.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function validEnvelope(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
|
17
|
+
return {
|
|
18
|
+
v: 1,
|
|
19
|
+
t: 'ping',
|
|
20
|
+
ch: generateChannelId(),
|
|
21
|
+
ts: Date.now(),
|
|
22
|
+
from: b64urlEncode(generateX25519KeyPair().publicKey),
|
|
23
|
+
body: {},
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Validate that the envelope has all required fields per Section 4.1. */
|
|
29
|
+
function validateEnvelope(msg: Record<string, unknown>): string[] {
|
|
30
|
+
const errors: string[] = [];
|
|
31
|
+
if (msg.v !== 1) errors.push('v must be 1');
|
|
32
|
+
if (typeof msg.t !== 'string' || msg.t.length === 0) errors.push('t must be a non-empty string');
|
|
33
|
+
if (typeof msg.ch !== 'string' || !/^[0-9a-f]{64}$/.test(msg.ch as string)) {
|
|
34
|
+
errors.push('ch must be 64 lowercase hex chars');
|
|
35
|
+
}
|
|
36
|
+
if (typeof msg.ts !== 'number') errors.push('ts must be a number');
|
|
37
|
+
if (typeof msg.from !== 'string' || (msg.from as string).length === 0) {
|
|
38
|
+
errors.push('from must be a non-empty string');
|
|
39
|
+
}
|
|
40
|
+
if (typeof msg.body !== 'object' || msg.body === null || Array.isArray(msg.body)) {
|
|
41
|
+
errors.push('body must be a non-null object');
|
|
42
|
+
}
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Validate body schema per Section 4.2. */
|
|
47
|
+
function validateBody(t: string, body: Record<string, unknown>): string[] {
|
|
48
|
+
const errors: string[] = [];
|
|
49
|
+
switch (t) {
|
|
50
|
+
case 'create':
|
|
51
|
+
if (!body.meta || typeof body.meta !== 'object') errors.push('create body requires meta object');
|
|
52
|
+
break;
|
|
53
|
+
case 'join':
|
|
54
|
+
if (!('sealed_join' in body)) errors.push('join body requires sealed_join field');
|
|
55
|
+
break;
|
|
56
|
+
case 'accept':
|
|
57
|
+
if (typeof body.target !== 'string') errors.push('accept body requires target string');
|
|
58
|
+
break;
|
|
59
|
+
case 'ready':
|
|
60
|
+
for (const field of ['state', 'role', 'self', 'remote', 'reconnect']) {
|
|
61
|
+
if (!(field in body)) errors.push(`ready body requires ${field}`);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
case 'req':
|
|
65
|
+
case 'res':
|
|
66
|
+
case 'evt':
|
|
67
|
+
if (typeof body.id !== 'string') errors.push(`${t} body requires id string`);
|
|
68
|
+
if (typeof body.sealed !== 'string') errors.push(`${t} body requires sealed string`);
|
|
69
|
+
break;
|
|
70
|
+
case 'ping':
|
|
71
|
+
case 'pong':
|
|
72
|
+
// empty body is valid
|
|
73
|
+
break;
|
|
74
|
+
case 'close':
|
|
75
|
+
if (typeof body.reason !== 'string') errors.push('close body requires reason string');
|
|
76
|
+
break;
|
|
77
|
+
case 'terminate':
|
|
78
|
+
if (typeof body.reason !== 'string') errors.push('terminate body requires reason string');
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
errors.push(`unknown message type: ${t}`);
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Check wire size limit per Section 15 rule 10. */
|
|
87
|
+
function checkSizeLimit(msg: Record<string, unknown>): boolean {
|
|
88
|
+
const wire = JSON.stringify(msg);
|
|
89
|
+
return new TextEncoder().encode(wire).length <= 64 * 1024;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Envelope validation (Section 4.1)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('Section 4.1 — Envelope required fields', () => {
|
|
97
|
+
it('valid envelope passes validation', () => {
|
|
98
|
+
expect(validateEnvelope(validEnvelope())).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('v must be 1', () => {
|
|
102
|
+
const errors = validateEnvelope(validEnvelope({ v: 2 }));
|
|
103
|
+
expect(errors).toContain('v must be 1');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('v=0 is rejected', () => {
|
|
107
|
+
const errors = validateEnvelope(validEnvelope({ v: 0 }));
|
|
108
|
+
expect(errors).toContain('v must be 1');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('t must be a non-empty string', () => {
|
|
112
|
+
expect(validateEnvelope(validEnvelope({ t: '' }))).toContain('t must be a non-empty string');
|
|
113
|
+
expect(validateEnvelope(validEnvelope({ t: 123 }))).toContain('t must be a non-empty string');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('ch must be 64 lowercase hex chars', () => {
|
|
117
|
+
expect(validateEnvelope(validEnvelope({ ch: 'ABC' }))).toContain('ch must be 64 lowercase hex chars');
|
|
118
|
+
expect(validateEnvelope(validEnvelope({ ch: 'zz'.repeat(32) }))).toContain('ch must be 64 lowercase hex chars');
|
|
119
|
+
expect(validateEnvelope(validEnvelope({ ch: 'aa'.repeat(31) }))).toContain('ch must be 64 lowercase hex chars');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('valid 64-hex-char ch passes', () => {
|
|
123
|
+
const msg = validEnvelope({ ch: 'ab'.repeat(32) });
|
|
124
|
+
expect(validateEnvelope(msg)).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('ts must be a number', () => {
|
|
128
|
+
expect(validateEnvelope(validEnvelope({ ts: 'not a number' }))).toContain('ts must be a number');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('from must be a non-empty string', () => {
|
|
132
|
+
expect(validateEnvelope(validEnvelope({ from: '' }))).toContain('from must be a non-empty string');
|
|
133
|
+
expect(validateEnvelope(validEnvelope({ from: 123 }))).toContain('from must be a non-empty string');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('body must be a non-null object', () => {
|
|
137
|
+
expect(validateEnvelope(validEnvelope({ body: null }))).toContain('body must be a non-null object');
|
|
138
|
+
expect(validateEnvelope(validEnvelope({ body: [1, 2] }))).toContain('body must be a non-null object');
|
|
139
|
+
expect(validateEnvelope(validEnvelope({ body: 'string' }))).toContain('body must be a non-null object');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Channel ID validation (Section 3)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('Section 3 — Channel ID validation', () => {
|
|
148
|
+
it('valid channel ID: 64 lowercase hex chars', () => {
|
|
149
|
+
const ch = generateChannelId();
|
|
150
|
+
expect(ch).toMatch(/^[0-9a-f]{64}$/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects uppercase hex', () => {
|
|
154
|
+
const ch = 'AB'.repeat(32);
|
|
155
|
+
expect(/^[0-9a-f]{64}$/.test(ch)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rejects 63 chars', () => {
|
|
159
|
+
const ch = 'a'.repeat(63);
|
|
160
|
+
expect(/^[0-9a-f]{64}$/.test(ch)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects 65 chars', () => {
|
|
164
|
+
const ch = 'a'.repeat(65);
|
|
165
|
+
expect(/^[0-9a-f]{64}$/.test(ch)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects non-hex chars', () => {
|
|
169
|
+
const ch = 'g'.repeat(64);
|
|
170
|
+
expect(/^[0-9a-f]{64}$/.test(ch)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Body schema validation (Section 4.2)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('Section 4.2 — Body schemas', () => {
|
|
179
|
+
it('create: requires meta object', () => {
|
|
180
|
+
expect(validateBody('create', { meta: { name: 'X', description: 'Y', url: 'Z', icon: 'W' } })).toEqual([]);
|
|
181
|
+
expect(validateBody('create', {})).toContain('create body requires meta object');
|
|
182
|
+
expect(validateBody('create', { meta: null })).toContain('create body requires meta object');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('join: requires sealed_join field (may be null for reconnect)', () => {
|
|
186
|
+
expect(validateBody('join', { sealed_join: 'abc123' })).toEqual([]);
|
|
187
|
+
expect(validateBody('join', { sealed_join: null })).toEqual([]);
|
|
188
|
+
expect(validateBody('join', {})).toContain('join body requires sealed_join field');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('accept: requires target string', () => {
|
|
192
|
+
expect(validateBody('accept', { target: 'pubkey_b64' })).toEqual([]);
|
|
193
|
+
expect(validateBody('accept', {})).toContain('accept body requires target string');
|
|
194
|
+
expect(validateBody('accept', { target: 123 })).toContain('accept body requires target string');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('ready: requires state, role, self, remote, reconnect', () => {
|
|
198
|
+
const valid = { state: 'connected', role: 'dapp', self: 'pk1', remote: 'pk2', reconnect: false };
|
|
199
|
+
expect(validateBody('ready', valid)).toEqual([]);
|
|
200
|
+
for (const field of ['state', 'role', 'self', 'remote', 'reconnect']) {
|
|
201
|
+
const missing = { ...valid };
|
|
202
|
+
delete (missing as any)[field];
|
|
203
|
+
expect(validateBody('ready', missing)).toContain(`ready body requires ${field}`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('req/res/evt: requires id and sealed strings', () => {
|
|
208
|
+
for (const t of ['req', 'res', 'evt']) {
|
|
209
|
+
expect(validateBody(t, { id: 'uuid', sealed: 'base64data' })).toEqual([]);
|
|
210
|
+
expect(validateBody(t, { sealed: 'base64data' })).toContain(`${t} body requires id string`);
|
|
211
|
+
expect(validateBody(t, { id: 'uuid' })).toContain(`${t} body requires sealed string`);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('ping/pong: empty body is valid', () => {
|
|
216
|
+
expect(validateBody('ping', {})).toEqual([]);
|
|
217
|
+
expect(validateBody('pong', {})).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('close: requires reason string', () => {
|
|
221
|
+
expect(validateBody('close', { reason: 'normal' })).toEqual([]);
|
|
222
|
+
expect(validateBody('close', {})).toContain('close body requires reason string');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('terminate: requires reason string', () => {
|
|
226
|
+
expect(validateBody('terminate', { reason: 'timeout' })).toEqual([]);
|
|
227
|
+
expect(validateBody('terminate', {})).toContain('terminate body requires reason string');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// from = "_adapter" rejection (Section 2)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe('Section 2 — _adapter from rejection', () => {
|
|
236
|
+
it('"_adapter" is reserved for adapter-sent messages only', () => {
|
|
237
|
+
// Peer message types that MUST NOT have from = "_adapter"
|
|
238
|
+
const peerTypes = ['create', 'join', 'accept', 'req', 'res', 'evt', 'ping', 'pong', 'close'];
|
|
239
|
+
for (const t of peerTypes) {
|
|
240
|
+
const msg = validEnvelope({ t, from: '_adapter' });
|
|
241
|
+
// Peers MUST reject any peer-sent message where from = "_adapter"
|
|
242
|
+
expect(msg.from).toBe('_adapter');
|
|
243
|
+
// Validation: from="_adapter" is only valid for adapter-sent types
|
|
244
|
+
const isAdapterType = t === 'ready' || t === 'terminate';
|
|
245
|
+
expect(isAdapterType).toBe(false);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('"_adapter" is valid for ready and terminate messages', () => {
|
|
250
|
+
const adapterTypes = ['ready', 'terminate'];
|
|
251
|
+
for (const t of adapterTypes) {
|
|
252
|
+
const isAdapterType = t === 'ready' || t === 'terminate';
|
|
253
|
+
expect(isAdapterType).toBe(true);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('from = "_adapter" MUST be rejected for peer message types', () => {
|
|
258
|
+
// This test documents the rule: implementations MUST reject peer messages
|
|
259
|
+
// with from = "_adapter" to prevent adapter impersonation
|
|
260
|
+
function isPeerMessageWithAdapterFrom(msg: { t: string; from: string }): boolean {
|
|
261
|
+
const peerTypes = new Set(['create', 'join', 'accept', 'req', 'res', 'evt', 'ping', 'pong', 'close']);
|
|
262
|
+
return peerTypes.has(msg.t) && msg.from === '_adapter';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
expect(isPeerMessageWithAdapterFrom({ t: 'req', from: '_adapter' })).toBe(true);
|
|
266
|
+
expect(isPeerMessageWithAdapterFrom({ t: 'req', from: 'some_pubkey' })).toBe(false);
|
|
267
|
+
expect(isPeerMessageWithAdapterFrom({ t: 'ready', from: '_adapter' })).toBe(false);
|
|
268
|
+
expect(isPeerMessageWithAdapterFrom({ t: 'terminate', from: '_adapter' })).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Message size limit (Section 15 rule 10)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe('Section 15 rule 10 — Message size limit (64 KB)', () => {
|
|
277
|
+
it('a normal message is within the limit', () => {
|
|
278
|
+
const msg = validEnvelope();
|
|
279
|
+
expect(checkSizeLimit(msg)).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('a message exceeding 64 KB is rejected', () => {
|
|
283
|
+
const msg = validEnvelope({
|
|
284
|
+
body: { sealed: 'x'.repeat(70000) },
|
|
285
|
+
});
|
|
286
|
+
expect(checkSizeLimit(msg)).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('exactly 64 KB is within the limit', () => {
|
|
290
|
+
// Build a message that is exactly at the boundary
|
|
291
|
+
const base = validEnvelope({ body: { sealed: '' } });
|
|
292
|
+
const baseSize = new TextEncoder().encode(JSON.stringify(base)).length;
|
|
293
|
+
const remaining = 64 * 1024 - baseSize;
|
|
294
|
+
const msg = validEnvelope({ body: { sealed: 'a'.repeat(remaining) } });
|
|
295
|
+
expect(checkSizeLimit(msg)).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Close / terminate reasons (Section 12.3)
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
describe('Section 12.3 — Close and terminate reasons', () => {
|
|
304
|
+
const peerReasons = [
|
|
305
|
+
'normal',
|
|
306
|
+
'user_rejected',
|
|
307
|
+
'unsupported_capability',
|
|
308
|
+
'unsupported_version',
|
|
309
|
+
'decryption_failed',
|
|
310
|
+
'invalid_state',
|
|
311
|
+
'invalid_role',
|
|
312
|
+
'timeout',
|
|
313
|
+
'rate_limited',
|
|
314
|
+
'payload_too_large',
|
|
315
|
+
'protocol_error',
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const adapterReasons = [
|
|
319
|
+
'channel_not_found',
|
|
320
|
+
'channel_exists',
|
|
321
|
+
'already_connected',
|
|
322
|
+
'invalid_state',
|
|
323
|
+
'invalid_role',
|
|
324
|
+
'timeout',
|
|
325
|
+
'rate_limited',
|
|
326
|
+
'payload_too_large',
|
|
327
|
+
'protocol_error',
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
it('all peer close reasons are valid strings', () => {
|
|
331
|
+
for (const reason of peerReasons) {
|
|
332
|
+
expect(typeof reason).toBe('string');
|
|
333
|
+
expect(reason.length).toBeGreaterThan(0);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('all adapter terminate reasons are valid strings', () => {
|
|
338
|
+
for (const reason of adapterReasons) {
|
|
339
|
+
expect(typeof reason).toBe('string');
|
|
340
|
+
expect(reason.length).toBeGreaterThan(0);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('terminate is adapter-only (from = "_adapter")', () => {
|
|
345
|
+
// Section 12.2: Only the adapter sends terminate. Peers MUST NOT send it.
|
|
346
|
+
const validTerminate = {
|
|
347
|
+
v: 1,
|
|
348
|
+
t: 'terminate',
|
|
349
|
+
ch: 'aa'.repeat(32),
|
|
350
|
+
ts: Date.now(),
|
|
351
|
+
from: '_adapter',
|
|
352
|
+
body: { reason: 'timeout' },
|
|
353
|
+
};
|
|
354
|
+
expect(validTerminate.from).toBe('_adapter');
|
|
355
|
+
});
|
|
356
|
+
});
|