walletpair-sdk 1.0.2 → 1.0.5
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/README.md +13 -0
- package/dist/ble/framing.d.ts.map +1 -1
- package/dist/ble/framing.js +2 -2
- package/dist/ble/framing.js.map +1 -1
- package/dist/ble/index.d.ts +2 -2
- package/dist/ble/index.d.ts.map +1 -1
- package/dist/ble/index.js +2 -2
- package/dist/ble/index.js.map +1 -1
- package/dist/ble/web-ble-transport.d.ts +1 -1
- package/dist/ble/web-ble-transport.d.ts.map +1 -1
- package/dist/ble/web-ble-transport.js +23 -12
- package/dist/ble/web-ble-transport.js.map +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +29 -12
- package/dist/crypto.js.map +1 -1
- package/dist/dapp-session.d.ts.map +1 -1
- package/dist/dapp-session.js +15 -5
- package/dist/dapp-session.js.map +1 -1
- package/dist/emitter.d.ts +1 -3
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +4 -2
- package/dist/emitter.js.map +1 -1
- package/dist/evm/eip1193.d.ts +2 -2
- package/dist/evm/eip1193.d.ts.map +1 -1
- package/dist/evm/eip1193.js +32 -18
- package/dist/evm/eip1193.js.map +1 -1
- package/dist/evm/index.d.ts +2 -2
- package/dist/evm/index.d.ts.map +1 -1
- package/dist/evm/index.js.map +1 -1
- package/dist/wallet-session.d.ts.map +1 -1
- package/dist/wallet-session.js +4 -3
- package/dist/wallet-session.js.map +1 -1
- package/dist/ws-transport.d.ts +3 -2
- package/dist/ws-transport.d.ts.map +1 -1
- package/dist/ws-transport.js +13 -4
- package/dist/ws-transport.js.map +1 -1
- package/package.json +20 -1
- package/src/__tests__/adversarial/crypto-attacks.test.ts +240 -233
- package/src/__tests__/adversarial/malicious-dapp.test.ts +228 -194
- package/src/__tests__/adversarial/malicious-relay.test.ts +292 -220
- package/src/__tests__/adversarial/malicious-wallet.test.ts +246 -180
- package/src/__tests__/spec-compliance/canonical-json.test.ts +105 -105
- package/src/__tests__/spec-compliance/crypto-vectors.test.ts +149 -154
- package/src/__tests__/spec-compliance/message-format.test.ts +180 -151
- package/src/__tests__/spec-compliance/sequence-numbers.test.ts +142 -149
- package/src/__tests__/spec-compliance/state-machine.test.ts +203 -180
- package/src/ble/framing.test.ts +122 -114
- package/src/ble/framing.ts +48 -51
- package/src/ble/index.ts +7 -7
- package/src/ble/web-ble-transport.test.ts +93 -84
- package/src/ble/web-ble-transport.ts +70 -57
- package/src/ble/web-bluetooth.d.ts +19 -19
- package/src/canonical-json.test.ts +301 -285
- package/src/crypto-directional.test.ts +155 -129
- package/src/crypto-hardening.test.ts +292 -283
- package/src/crypto.test.ts +364 -346
- package/src/crypto.ts +185 -175
- package/src/dapp-session.test.ts +522 -385
- package/src/dapp-session.ts +17 -11
- package/src/emitter.test.ts +122 -122
- package/src/emitter.ts +20 -18
- package/src/evm/eip1193.test.ts +283 -205
- package/src/evm/eip1193.ts +162 -138
- package/src/evm/index.ts +5 -5
- package/src/evm/wagmi.test.ts +1 -1
- package/src/integration.test.ts +329 -201
- package/src/security.test.ts +331 -238
- package/src/sequence-validation.test.ts +6 -9
- package/src/test-helpers.ts +102 -78
- package/src/types.test.ts +45 -50
- package/src/wallet-session.test.ts +611 -383
- package/src/wallet-session.ts +7 -9
- package/src/ws-transport.test.ts +141 -139
- package/src/ws-transport.ts +52 -41
package/src/crypto.ts
CHANGED
|
@@ -4,48 +4,46 @@
|
|
|
4
4
|
* Pure JS (noble libraries v2), no native modules required.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import _canonicalize from 'canonicalize'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from '@noble/hashes/
|
|
20
|
-
|
|
21
|
-
|
|
7
|
+
import _canonicalize from 'canonicalize'
|
|
8
|
+
|
|
9
|
+
// Handle CJS/ESM interop: bundlers may wrap the default export in { default: fn }
|
|
10
|
+
const canonicalize: (input: unknown) => string | undefined =
|
|
11
|
+
typeof _canonicalize === 'function'
|
|
12
|
+
? _canonicalize
|
|
13
|
+
: (_canonicalize as unknown as { default: (input: unknown) => string | undefined }).default
|
|
14
|
+
|
|
15
|
+
import { chacha20poly1305 } from '@noble/ciphers/chacha'
|
|
16
|
+
import { x25519 } from '@noble/curves/ed25519'
|
|
17
|
+
import { hkdf } from '@noble/hashes/hkdf'
|
|
18
|
+
import { hmac } from '@noble/hashes/hmac'
|
|
19
|
+
import { sha256 } from '@noble/hashes/sha256'
|
|
20
|
+
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'
|
|
21
|
+
|
|
22
|
+
import type { PairingParams } from './types.js'
|
|
22
23
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Re-exports
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
|
|
27
|
-
export { bytesToHex, hexToBytes }
|
|
28
|
+
export { bytesToHex, hexToBytes }
|
|
28
29
|
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
// Base64url (no padding)
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
|
|
33
34
|
export function b64urlEncode(bytes: Uint8Array): string {
|
|
34
|
-
let binary = ''
|
|
35
|
-
for (const b of bytes) binary += String.fromCharCode(b)
|
|
36
|
-
return btoa(binary)
|
|
37
|
-
.replace(/\+/g, '-')
|
|
38
|
-
.replace(/\//g, '_')
|
|
39
|
-
.replace(/=/g, '');
|
|
35
|
+
let binary = ''
|
|
36
|
+
for (const b of bytes) binary += String.fromCharCode(b)
|
|
37
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
export function b64urlDecode(str: string): Uint8Array {
|
|
43
|
-
const b64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
44
|
-
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
|
|
45
|
-
const binary = atob(padded)
|
|
46
|
-
const bytes = new Uint8Array(binary.length)
|
|
47
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
48
|
-
return bytes
|
|
41
|
+
const b64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
42
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
|
|
43
|
+
const binary = atob(padded)
|
|
44
|
+
const bytes = new Uint8Array(binary.length)
|
|
45
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
46
|
+
return bytes
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
// ---------------------------------------------------------------------------
|
|
@@ -53,19 +51,19 @@ export function b64urlDecode(str: string): Uint8Array {
|
|
|
53
51
|
// ---------------------------------------------------------------------------
|
|
54
52
|
|
|
55
53
|
export interface X25519KeyPair {
|
|
56
|
-
privateKey: Uint8Array
|
|
57
|
-
publicKey: Uint8Array
|
|
58
|
-
publicKeyB64: string
|
|
54
|
+
privateKey: Uint8Array
|
|
55
|
+
publicKey: Uint8Array
|
|
56
|
+
publicKeyB64: string
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
export function generateX25519KeyPair(): X25519KeyPair {
|
|
62
|
-
const privateKey = x25519.utils.randomPrivateKey()
|
|
63
|
-
const publicKey = x25519.getPublicKey(privateKey)
|
|
64
|
-
return { privateKey, publicKey, publicKeyB64: b64urlEncode(publicKey) }
|
|
60
|
+
const privateKey = x25519.utils.randomPrivateKey()
|
|
61
|
+
const publicKey = x25519.getPublicKey(privateKey)
|
|
62
|
+
return { privateKey, publicKey, publicKeyB64: b64urlEncode(publicKey) }
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
export function getPublicKey(privateKey: Uint8Array): Uint8Array {
|
|
68
|
-
return x25519.getPublicKey(privateKey)
|
|
66
|
+
return x25519.getPublicKey(privateKey)
|
|
69
67
|
}
|
|
70
68
|
|
|
71
69
|
// ---------------------------------------------------------------------------
|
|
@@ -77,58 +75,57 @@ export function computeSharedSecret(
|
|
|
77
75
|
remotePubKey: Uint8Array,
|
|
78
76
|
): Uint8Array {
|
|
79
77
|
if (remotePubKey.length !== 32) {
|
|
80
|
-
throw new Error('Remote public key must be exactly 32 bytes')
|
|
78
|
+
throw new Error('Remote public key must be exactly 32 bytes')
|
|
81
79
|
}
|
|
82
|
-
const shared = x25519.getSharedSecret(myPrivateKey, remotePubKey)
|
|
80
|
+
const shared = x25519.getSharedSecret(myPrivateKey, remotePubKey)
|
|
83
81
|
// RFC 7748 §6: low-order points produce all-zero output — reject to
|
|
84
82
|
// prevent invalid key derivation.
|
|
85
|
-
let acc = 0
|
|
86
|
-
for (let i = 0; i < shared.length; i++) acc |= shared[i]
|
|
83
|
+
let acc = 0
|
|
84
|
+
for (let i = 0; i < shared.length; i++) acc |= shared[i] ?? 0
|
|
87
85
|
if (acc === 0) {
|
|
88
|
-
throw new Error('X25519 produced all-zero shared secret (low-order public key)')
|
|
86
|
+
throw new Error('X25519 produced all-zero shared secret (low-order public key)')
|
|
89
87
|
}
|
|
90
|
-
return shared
|
|
88
|
+
return shared
|
|
91
89
|
}
|
|
92
90
|
|
|
93
|
-
export function deriveSessionKey(
|
|
94
|
-
sharedSecret
|
|
95
|
-
channelIdHex: string,
|
|
96
|
-
): Uint8Array {
|
|
97
|
-
return hkdf(sha256, sharedSecret, hexToBytes(channelIdHex), 'walletpair-v1 root', 32);
|
|
91
|
+
export function deriveSessionKey(sharedSecret: Uint8Array, channelIdHex: string): Uint8Array {
|
|
92
|
+
return hkdf(sha256, sharedSecret, hexToBytes(channelIdHex), 'walletpair-v1 root', 32)
|
|
98
93
|
}
|
|
99
94
|
|
|
100
95
|
export interface SessionCryptoContext {
|
|
101
|
-
dappPubKeyB64: string
|
|
102
|
-
walletPubKeyB64: string
|
|
103
|
-
capabilities?: unknown
|
|
104
|
-
walletMeta?: unknown
|
|
105
|
-
dappName?: string | undefined
|
|
96
|
+
dappPubKeyB64: string
|
|
97
|
+
walletPubKeyB64: string
|
|
98
|
+
capabilities?: unknown
|
|
99
|
+
walletMeta?: unknown
|
|
100
|
+
dappName?: string | undefined
|
|
106
101
|
}
|
|
107
102
|
|
|
108
103
|
export interface DirectionalSessionKeys {
|
|
109
|
-
rootKey: Uint8Array
|
|
110
|
-
dappToWalletKey: Uint8Array
|
|
111
|
-
walletToDappKey: Uint8Array
|
|
112
|
-
transcriptHash: Uint8Array
|
|
104
|
+
rootKey: Uint8Array
|
|
105
|
+
dappToWalletKey: Uint8Array
|
|
106
|
+
walletToDappKey: Uint8Array
|
|
107
|
+
transcriptHash: Uint8Array
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
export function canonicalJson(value: unknown): string {
|
|
116
|
-
return canonicalize(value) ?? 'null'
|
|
111
|
+
return canonicalize(value) ?? 'null'
|
|
117
112
|
}
|
|
118
113
|
|
|
119
114
|
export function computeHandshakeTranscriptHash(
|
|
120
115
|
channelIdHex: string,
|
|
121
116
|
context: SessionCryptoContext,
|
|
122
117
|
): Uint8Array {
|
|
123
|
-
return sha256(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
return sha256(
|
|
119
|
+
concatBytes(
|
|
120
|
+
utf8ToBytes('walletpair-v1-transcript'),
|
|
121
|
+
hexToBytes(channelIdHex),
|
|
122
|
+
lp(context.dappPubKeyB64),
|
|
123
|
+
lp(context.walletPubKeyB64),
|
|
124
|
+
lp(canonicalJson(context.capabilities ?? null)),
|
|
125
|
+
lp(canonicalJson(context.walletMeta ?? null)),
|
|
126
|
+
lp(context.dappName ?? ''),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
132
129
|
}
|
|
133
130
|
|
|
134
131
|
export function deriveDirectionalSessionKeys(
|
|
@@ -136,41 +133,37 @@ export function deriveDirectionalSessionKeys(
|
|
|
136
133
|
channelIdHex: string,
|
|
137
134
|
context: SessionCryptoContext,
|
|
138
135
|
): DirectionalSessionKeys {
|
|
139
|
-
const transcriptHash = computeHandshakeTranscriptHash(channelIdHex, context)
|
|
136
|
+
const transcriptHash = computeHandshakeTranscriptHash(channelIdHex, context)
|
|
140
137
|
return {
|
|
141
138
|
rootKey,
|
|
142
139
|
transcriptHash,
|
|
143
140
|
dappToWalletKey: hkdf(sha256, rootKey, transcriptHash, 'walletpair-v1 dapp-to-wallet', 32),
|
|
144
141
|
walletToDappKey: hkdf(sha256, rootKey, transcriptHash, 'walletpair-v1 wallet-to-dapp', 32),
|
|
145
|
-
}
|
|
142
|
+
}
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
// ---------------------------------------------------------------------------
|
|
149
146
|
// Session fingerprint (protocol Section 7.3)
|
|
150
147
|
// ---------------------------------------------------------------------------
|
|
151
148
|
|
|
152
|
-
export function computeSessionFingerprint(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
return (view.getUint32(0) % 10000).toString().padStart(4, '0');
|
|
149
|
+
export function computeSessionFingerprint(channelIdHex: string, dappPubKeyB64: string): string {
|
|
150
|
+
const hash = sha256(
|
|
151
|
+
concatBytes(
|
|
152
|
+
utf8ToBytes('walletpair-v1-session-fingerprint'),
|
|
153
|
+
hexToBytes(channelIdHex),
|
|
154
|
+
b64urlDecode(dappPubKeyB64),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
const view = new DataView(hash.buffer, hash.byteOffset, 4)
|
|
158
|
+
return (view.getUint32(0) % 10000).toString().padStart(4, '0')
|
|
163
159
|
}
|
|
164
160
|
|
|
165
161
|
// ---------------------------------------------------------------------------
|
|
166
162
|
// Join encryption key (protocol Section 7.5 — private handshake)
|
|
167
163
|
// ---------------------------------------------------------------------------
|
|
168
164
|
|
|
169
|
-
export function deriveJoinEncryptionKey(
|
|
170
|
-
rootKey
|
|
171
|
-
channelIdHex: string,
|
|
172
|
-
): Uint8Array {
|
|
173
|
-
return hkdf(sha256, rootKey, hexToBytes(channelIdHex), 'walletpair-v1 join-encryption', 32);
|
|
165
|
+
export function deriveJoinEncryptionKey(rootKey: Uint8Array, channelIdHex: string): Uint8Array {
|
|
166
|
+
return hkdf(sha256, rootKey, hexToBytes(channelIdHex), 'walletpair-v1 join-encryption', 32)
|
|
174
167
|
}
|
|
175
168
|
|
|
176
169
|
/**
|
|
@@ -183,12 +176,12 @@ export function sealJoin(
|
|
|
183
176
|
capabilities: unknown,
|
|
184
177
|
meta?: unknown,
|
|
185
178
|
): string {
|
|
186
|
-
const plainObj: Record<string, unknown> = { capabilities, meta: meta ?? null }
|
|
187
|
-
const plaintext = utf8ToBytes(canonicalJson(plainObj))
|
|
188
|
-
const nonce = crypto.getRandomValues(new Uint8Array(12))
|
|
189
|
-
const aad = concatBytes(hexToBytes(channelIdHex), new Uint8Array([0x04]))
|
|
190
|
-
const ciphertext = chacha20poly1305(joinEncryptionKey, nonce, aad).encrypt(plaintext)
|
|
191
|
-
return b64urlEncode(concatBytes(nonce, ciphertext))
|
|
179
|
+
const plainObj: Record<string, unknown> = { capabilities, meta: meta ?? null }
|
|
180
|
+
const plaintext = utf8ToBytes(canonicalJson(plainObj))
|
|
181
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12))
|
|
182
|
+
const aad = concatBytes(hexToBytes(channelIdHex), new Uint8Array([0x04]))
|
|
183
|
+
const ciphertext = chacha20poly1305(joinEncryptionKey, nonce, aad).encrypt(plaintext)
|
|
184
|
+
return b64urlEncode(concatBytes(nonce, ciphertext))
|
|
192
185
|
}
|
|
193
186
|
|
|
194
187
|
/**
|
|
@@ -200,15 +193,19 @@ export function unsealJoin(
|
|
|
200
193
|
channelIdHex: string,
|
|
201
194
|
sealedJoin: string,
|
|
202
195
|
): { capabilities: unknown; meta?: unknown } {
|
|
203
|
-
const envelope = b64urlDecode(sealedJoin)
|
|
196
|
+
const envelope = b64urlDecode(sealedJoin)
|
|
204
197
|
if (envelope.length < 12 + 16) {
|
|
205
|
-
throw new Error('Invalid sealed_join envelope')
|
|
198
|
+
throw new Error('Invalid sealed_join envelope')
|
|
199
|
+
}
|
|
200
|
+
const nonce = envelope.slice(0, 12)
|
|
201
|
+
const ciphertext = envelope.slice(12)
|
|
202
|
+
const aad = concatBytes(hexToBytes(channelIdHex), new Uint8Array([0x04]))
|
|
203
|
+
const plaintext = chacha20poly1305(joinEncryptionKey, nonce, aad).decrypt(ciphertext)
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(new TextDecoder().decode(plaintext))
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error('Decrypted sealed_join payload is not valid JSON')
|
|
206
208
|
}
|
|
207
|
-
const nonce = envelope.slice(0, 12);
|
|
208
|
-
const ciphertext = envelope.slice(12);
|
|
209
|
-
const aad = concatBytes(hexToBytes(channelIdHex), new Uint8Array([0x04]));
|
|
210
|
-
const plaintext = chacha20poly1305(joinEncryptionKey, nonce, aad).decrypt(ciphertext);
|
|
211
|
-
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
212
209
|
}
|
|
213
210
|
|
|
214
211
|
// ---------------------------------------------------------------------------
|
|
@@ -222,35 +219,38 @@ export function unsealJoin(
|
|
|
222
219
|
export type AadHeader =
|
|
223
220
|
| { type: 'req'; from: string; id: string }
|
|
224
221
|
| { type: 'res'; from: string; id: string }
|
|
225
|
-
| { type: 'evt'; from: string; id: string }
|
|
222
|
+
| { type: 'evt'; from: string; id: string }
|
|
226
223
|
|
|
227
224
|
/** Length-prefix a UTF-8 string: uint16_be(byte_length) || utf8_bytes */
|
|
228
225
|
function lp(s: string): Uint8Array {
|
|
229
|
-
const bytes = utf8ToBytes(s)
|
|
226
|
+
const bytes = utf8ToBytes(s)
|
|
230
227
|
if (bytes.length > 0xffff) {
|
|
231
|
-
throw new Error('AAD field exceeds 65535 bytes')
|
|
228
|
+
throw new Error('AAD field exceeds 65535 bytes')
|
|
232
229
|
}
|
|
233
|
-
const len = new Uint8Array(2)
|
|
234
|
-
new DataView(len.buffer).setUint16(0, bytes.length)
|
|
235
|
-
return concatBytes(len, bytes)
|
|
230
|
+
const len = new Uint8Array(2)
|
|
231
|
+
new DataView(len.buffer).setUint16(0, bytes.length)
|
|
232
|
+
return concatBytes(len, bytes)
|
|
236
233
|
}
|
|
237
234
|
|
|
238
235
|
/**
|
|
239
236
|
* Build AEAD AAD = channel_id_bytes || type_byte || lp(fields...)
|
|
240
237
|
*/
|
|
241
238
|
function buildAad(channelIdHex: string, header?: AadHeader): Uint8Array {
|
|
242
|
-
const chBytes = hexToBytes(channelIdHex)
|
|
243
|
-
if (!header) return chBytes
|
|
239
|
+
const chBytes = hexToBytes(channelIdHex)
|
|
240
|
+
if (!header) return chBytes
|
|
244
241
|
switch (header.type) {
|
|
245
242
|
case 'req':
|
|
246
|
-
return concatBytes(chBytes, new Uint8Array([0x01]), lp(header.from), lp(header.id))
|
|
243
|
+
return concatBytes(chBytes, new Uint8Array([0x01]), lp(header.from), lp(header.id))
|
|
247
244
|
case 'res':
|
|
248
|
-
return concatBytes(chBytes, new Uint8Array([0x02]), lp(header.from), lp(header.id))
|
|
245
|
+
return concatBytes(chBytes, new Uint8Array([0x02]), lp(header.from), lp(header.id))
|
|
249
246
|
case 'evt':
|
|
250
|
-
return concatBytes(chBytes, new Uint8Array([0x03]), lp(header.from), lp(header.id))
|
|
247
|
+
return concatBytes(chBytes, new Uint8Array([0x03]), lp(header.from), lp(header.id))
|
|
251
248
|
}
|
|
252
249
|
}
|
|
253
250
|
|
|
251
|
+
/** Maximum sequence number (2^32 - 1). Session MUST close before reaching this. */
|
|
252
|
+
const MAX_SEQ = 0xffffffff
|
|
253
|
+
|
|
254
254
|
export function sealPayload(
|
|
255
255
|
encryptionKey: Uint8Array,
|
|
256
256
|
channelIdHex: string,
|
|
@@ -258,13 +258,16 @@ export function sealPayload(
|
|
|
258
258
|
data: unknown,
|
|
259
259
|
header?: AadHeader,
|
|
260
260
|
): string {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
261
|
+
if (!Number.isInteger(seq) || seq < 0 || seq > MAX_SEQ) {
|
|
262
|
+
throw new Error(`Sequence number out of range: ${seq} (must be 0..${MAX_SEQ})`)
|
|
263
|
+
}
|
|
264
|
+
const seqBytes = new Uint8Array(4)
|
|
265
|
+
new DataView(seqBytes.buffer).setUint32(0, seq)
|
|
266
|
+
const nonce = hmac(sha256, encryptionKey, seqBytes).slice(0, 12)
|
|
267
|
+
const plaintext = utf8ToBytes(canonicalJson(data))
|
|
268
|
+
const aad = buildAad(channelIdHex, header)
|
|
269
|
+
const ciphertext = chacha20poly1305(encryptionKey, nonce, aad).encrypt(plaintext)
|
|
270
|
+
return b64urlEncode(concatBytes(seqBytes, ciphertext))
|
|
268
271
|
}
|
|
269
272
|
|
|
270
273
|
export function unsealPayload(
|
|
@@ -273,43 +276,49 @@ export function unsealPayload(
|
|
|
273
276
|
sealed: string,
|
|
274
277
|
header?: AadHeader,
|
|
275
278
|
): { seq: number; data: unknown; plaintext: Uint8Array; plaintextJson: string } {
|
|
276
|
-
const bytes = b64urlDecode(sealed)
|
|
277
|
-
const seqBytes = bytes.slice(0, 4)
|
|
278
|
-
const ciphertext = bytes.slice(4)
|
|
279
|
-
const nonce = hmac(sha256, encryptionKey, seqBytes).slice(0, 12)
|
|
280
|
-
const aad = buildAad(channelIdHex, header)
|
|
281
|
-
const plaintext = chacha20poly1305(encryptionKey, nonce, aad).decrypt(ciphertext)
|
|
282
|
-
const seq = new DataView(seqBytes.buffer, seqBytes.byteOffset, 4).getUint32(0)
|
|
283
|
-
const plaintextJson = new TextDecoder().decode(plaintext)
|
|
284
|
-
|
|
279
|
+
const bytes = b64urlDecode(sealed)
|
|
280
|
+
const seqBytes = bytes.slice(0, 4)
|
|
281
|
+
const ciphertext = bytes.slice(4)
|
|
282
|
+
const nonce = hmac(sha256, encryptionKey, seqBytes).slice(0, 12)
|
|
283
|
+
const aad = buildAad(channelIdHex, header)
|
|
284
|
+
const plaintext = chacha20poly1305(encryptionKey, nonce, aad).decrypt(ciphertext)
|
|
285
|
+
const seq = new DataView(seqBytes.buffer, seqBytes.byteOffset, 4).getUint32(0)
|
|
286
|
+
const plaintextJson = new TextDecoder().decode(plaintext)
|
|
287
|
+
let data: unknown
|
|
288
|
+
try {
|
|
289
|
+
data = JSON.parse(plaintextJson)
|
|
290
|
+
} catch {
|
|
291
|
+
throw new Error('Decrypted payload is not valid JSON')
|
|
292
|
+
}
|
|
293
|
+
return { seq, data, plaintext, plaintextJson }
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
export function sha256Hex(bytes: Uint8Array): string {
|
|
288
|
-
return bytesToHex(sha256(bytes))
|
|
297
|
+
return bytesToHex(sha256(bytes))
|
|
289
298
|
}
|
|
290
299
|
|
|
291
300
|
/** Constant-time string comparison to prevent timing side-channels (§9.1). */
|
|
292
301
|
export function constantTimeEqual(a: string, b: string): boolean {
|
|
293
|
-
if (a.length !== b.length) return false
|
|
294
|
-
let result = 0
|
|
302
|
+
if (a.length !== b.length) return false
|
|
303
|
+
let result = 0
|
|
295
304
|
for (let i = 0; i < a.length; i++) {
|
|
296
|
-
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
305
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
297
306
|
}
|
|
298
|
-
return result === 0
|
|
307
|
+
return result === 0
|
|
299
308
|
}
|
|
300
309
|
|
|
301
310
|
// ---------------------------------------------------------------------------
|
|
302
311
|
// Snapshot integrity (HMAC for serialized session state)
|
|
303
312
|
// ---------------------------------------------------------------------------
|
|
304
313
|
|
|
305
|
-
const SNAPSHOT_HMAC_INFO = utf8ToBytes('walletpair-v1-snapshot-hmac')
|
|
314
|
+
const SNAPSHOT_HMAC_INFO = utf8ToBytes('walletpair-v1-snapshot-hmac')
|
|
306
315
|
|
|
307
316
|
/**
|
|
308
317
|
* Derive a dedicated HMAC key from the send key so we don't reuse
|
|
309
318
|
* traffic keys for a different purpose.
|
|
310
319
|
*/
|
|
311
320
|
function deriveSnapshotHmacKey(sendKey: Uint8Array): Uint8Array {
|
|
312
|
-
return hmac(sha256, sendKey, SNAPSHOT_HMAC_INFO)
|
|
321
|
+
return hmac(sha256, sendKey, SNAPSHOT_HMAC_INFO)
|
|
313
322
|
}
|
|
314
323
|
|
|
315
324
|
/**
|
|
@@ -317,9 +326,9 @@ function deriveSnapshotHmacKey(sendKey: Uint8Array): Uint8Array {
|
|
|
317
326
|
* Returns `<hex-mac>.<json-payload>`.
|
|
318
327
|
*/
|
|
319
328
|
export function signSnapshot(sendKey: Uint8Array, json: string): string {
|
|
320
|
-
const macKey = deriveSnapshotHmacKey(sendKey)
|
|
321
|
-
const mac = hmac(sha256, macKey, utf8ToBytes(json))
|
|
322
|
-
return bytesToHex(mac)
|
|
329
|
+
const macKey = deriveSnapshotHmacKey(sendKey)
|
|
330
|
+
const mac = hmac(sha256, macKey, utf8ToBytes(json))
|
|
331
|
+
return `${bytesToHex(mac)}.${json}`
|
|
323
332
|
}
|
|
324
333
|
|
|
325
334
|
/**
|
|
@@ -327,13 +336,13 @@ export function signSnapshot(sendKey: Uint8Array, json: string): string {
|
|
|
327
336
|
* Returns the JSON payload on success, or `null` if the HMAC is invalid.
|
|
328
337
|
*/
|
|
329
338
|
export function verifySnapshot(sendKey: Uint8Array, signed: string): string | null {
|
|
330
|
-
const dot = signed.indexOf('.')
|
|
331
|
-
if (dot !== 64) return null
|
|
332
|
-
const macHex = signed.slice(0, 64)
|
|
333
|
-
const json = signed.slice(65)
|
|
334
|
-
const macKey = deriveSnapshotHmacKey(sendKey)
|
|
335
|
-
const expected = bytesToHex(hmac(sha256, macKey, utf8ToBytes(json)))
|
|
336
|
-
return constantTimeEqual(macHex, expected) ? json : null
|
|
339
|
+
const dot = signed.indexOf('.')
|
|
340
|
+
if (dot !== 64) return null // HMAC-SHA256 hex = 64 chars
|
|
341
|
+
const macHex = signed.slice(0, 64)
|
|
342
|
+
const json = signed.slice(65)
|
|
343
|
+
const macKey = deriveSnapshotHmacKey(sendKey)
|
|
344
|
+
const expected = bytesToHex(hmac(sha256, macKey, utf8ToBytes(json)))
|
|
345
|
+
return constantTimeEqual(macHex, expected) ? json : null
|
|
337
346
|
}
|
|
338
347
|
|
|
339
348
|
// ---------------------------------------------------------------------------
|
|
@@ -341,7 +350,7 @@ export function verifySnapshot(sendKey: Uint8Array, signed: string): string | nu
|
|
|
341
350
|
// ---------------------------------------------------------------------------
|
|
342
351
|
|
|
343
352
|
export function generateChannelId(): string {
|
|
344
|
-
return bytesToHex(crypto.getRandomValues(new Uint8Array(32)))
|
|
353
|
+
return bytesToHex(crypto.getRandomValues(new Uint8Array(32)))
|
|
345
354
|
}
|
|
346
355
|
|
|
347
356
|
// ---------------------------------------------------------------------------
|
|
@@ -349,49 +358,50 @@ export function generateChannelId(): string {
|
|
|
349
358
|
// ---------------------------------------------------------------------------
|
|
350
359
|
|
|
351
360
|
export function buildPairingUri(params: {
|
|
352
|
-
channelId: string
|
|
353
|
-
pubkeyB64: string
|
|
354
|
-
relayUrl?: string | undefined
|
|
355
|
-
name: string
|
|
356
|
-
url: string
|
|
357
|
-
icon: string
|
|
361
|
+
channelId: string
|
|
362
|
+
pubkeyB64: string
|
|
363
|
+
relayUrl?: string | undefined
|
|
364
|
+
name: string
|
|
365
|
+
url: string
|
|
366
|
+
icon: string
|
|
358
367
|
/** Methods the dApp intends to call (§9.1). */
|
|
359
|
-
methods?: string[] | undefined
|
|
368
|
+
methods?: string[] | undefined
|
|
360
369
|
/** CAIP-2 chains the dApp intends to use (§9.1). */
|
|
361
|
-
chains?: string[] | undefined
|
|
370
|
+
chains?: string[] | undefined
|
|
362
371
|
}): string {
|
|
363
|
-
let uri = `walletpair:?ch=${params.channelId}&pubkey=${params.pubkeyB64}
|
|
364
|
-
if (params.relayUrl) uri += `&relay=${encodeURIComponent(params.relayUrl)}
|
|
365
|
-
uri += `&name=${encodeURIComponent(params.name)}
|
|
366
|
-
uri += `&url=${encodeURIComponent(params.url)}
|
|
367
|
-
uri += `&icon=${encodeURIComponent(params.icon)}
|
|
368
|
-
if (params.methods?.length) uri += `&methods=${params.methods.join(',')}
|
|
369
|
-
if (params.chains?.length) uri += `&chains=${params.chains.join(',')}
|
|
370
|
-
return uri
|
|
372
|
+
let uri = `walletpair:?ch=${params.channelId}&pubkey=${params.pubkeyB64}`
|
|
373
|
+
if (params.relayUrl) uri += `&relay=${encodeURIComponent(params.relayUrl)}`
|
|
374
|
+
uri += `&name=${encodeURIComponent(params.name)}`
|
|
375
|
+
uri += `&url=${encodeURIComponent(params.url)}`
|
|
376
|
+
uri += `&icon=${encodeURIComponent(params.icon)}`
|
|
377
|
+
if (params.methods?.length) uri += `&methods=${params.methods.join(',')}`
|
|
378
|
+
if (params.chains?.length) uri += `&chains=${params.chains.join(',')}`
|
|
379
|
+
return uri
|
|
371
380
|
}
|
|
372
381
|
|
|
373
382
|
export function parsePairingUri(uri: string): PairingParams {
|
|
374
|
-
const qs = uri.replace(/^walletpair:\?/, '')
|
|
375
|
-
const params = new URLSearchParams(qs)
|
|
376
|
-
const ch = params.get('ch')
|
|
377
|
-
const pubkey = params.get('pubkey')
|
|
378
|
-
if (!ch || !pubkey) throw new Error('Invalid pairing URI: missing ch or pubkey')
|
|
383
|
+
const qs = uri.replace(/^walletpair:\?/, '')
|
|
384
|
+
const params = new URLSearchParams(qs)
|
|
385
|
+
const ch = params.get('ch')
|
|
386
|
+
const pubkey = params.get('pubkey')
|
|
387
|
+
if (!ch || !pubkey) throw new Error('Invalid pairing URI: missing ch or pubkey')
|
|
379
388
|
// §8.1: ch must be 64 hex characters (32 bytes)
|
|
380
|
-
if (!/^[0-9a-f]{64}$/.test(ch))
|
|
389
|
+
if (!/^[0-9a-f]{64}$/.test(ch))
|
|
390
|
+
throw new Error('Invalid pairing URI: ch must be 64 lowercase hex chars')
|
|
381
391
|
// §8.1: pubkey must decode to 32 bytes
|
|
382
|
-
const pubkeyBytes = b64urlDecode(pubkey)
|
|
383
|
-
if (pubkeyBytes.length !== 32) throw new Error('Invalid pairing URI: pubkey must be 32 bytes')
|
|
392
|
+
const pubkeyBytes = b64urlDecode(pubkey)
|
|
393
|
+
if (pubkeyBytes.length !== 32) throw new Error('Invalid pairing URI: pubkey must be 32 bytes')
|
|
384
394
|
// §8.1: name, url, icon are required
|
|
385
|
-
const name = params.get('name')
|
|
386
|
-
const url = params.get('url')
|
|
387
|
-
const icon = params.get('icon')
|
|
388
|
-
if (!name) throw new Error('Invalid pairing URI: missing required param "name"')
|
|
389
|
-
if (!url) throw new Error('Invalid pairing URI: missing required param "url"')
|
|
390
|
-
if (!icon) throw new Error('Invalid pairing URI: missing required param "icon"')
|
|
395
|
+
const name = params.get('name')
|
|
396
|
+
const url = params.get('url')
|
|
397
|
+
const icon = params.get('icon')
|
|
398
|
+
if (!name) throw new Error('Invalid pairing URI: missing required param "name"')
|
|
399
|
+
if (!url) throw new Error('Invalid pairing URI: missing required param "url"')
|
|
400
|
+
if (!icon) throw new Error('Invalid pairing URI: missing required param "icon"')
|
|
391
401
|
// §8.1: icon MUST be https:
|
|
392
|
-
if (!icon.startsWith('https:')) throw new Error('Invalid pairing URI: icon must use https:')
|
|
393
|
-
const methodsStr = params.get('methods')
|
|
394
|
-
const chainsStr = params.get('chains')
|
|
402
|
+
if (!icon.startsWith('https:')) throw new Error('Invalid pairing URI: icon must use https:')
|
|
403
|
+
const methodsStr = params.get('methods')
|
|
404
|
+
const chainsStr = params.get('chains')
|
|
395
405
|
return {
|
|
396
406
|
ch,
|
|
397
407
|
pubkey,
|
|
@@ -401,5 +411,5 @@ export function parsePairingUri(uri: string): PairingParams {
|
|
|
401
411
|
icon,
|
|
402
412
|
methods: methodsStr ? methodsStr.split(',').filter(Boolean) : undefined,
|
|
403
413
|
chains: chainsStr ? chainsStr.split(',').filter(Boolean) : undefined,
|
|
404
|
-
}
|
|
414
|
+
}
|
|
405
415
|
}
|