@ninsei-labs/sdk 0.39.2 → 0.40.0-develop.1
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/CHANGELOG.md +63 -0
- package/README.md +4 -1
- package/engine/atomic/dleq2.js +86 -111
- package/engine/atomic/halfenc.js +58 -72
- package/engine/atomic/halves.js +31 -53
- package/engine/atomic/order-client.js +15 -16
- package/engine/atomic/order.js +44 -62
- package/engine/atomic/orderContext.js +26 -50
- package/engine/atomic/orderHalfCommit.js +17 -18
- package/engine/core/chainSource.js +54 -69
- package/engine/core/clock.js +11 -12
- package/engine/core/config.js +554 -449
- package/engine/core/finality.js +34 -53
- package/engine/core/format.js +35 -36
- package/engine/core/preSignGuards.js +109 -117
- package/engine/core/rfqSource.js +57 -75
- package/engine/core/sellWindow.js +23 -24
- package/engine/core/store.js +20 -21
- package/engine/core/swap.js +242 -243
- package/engine/evm/amounts.js +50 -51
- package/engine/evm/asyncExec.js +577 -0
- package/engine/evm/asyncLegs.js +308 -0
- package/engine/evm/asyncRoute.js +179 -0
- package/engine/evm/auth.js +41 -48
- package/engine/evm/claimGas.js +28 -47
- package/engine/evm/depositor.js +39 -43
- package/engine/evm/dex.js +266 -267
- package/engine/evm/escrow.js +113 -129
- package/engine/evm/factory.js +71 -77
- package/engine/evm/fees.js +41 -42
- package/engine/evm/funding.js +160 -162
- package/engine/evm/gasReserve.js +67 -36
- package/engine/evm/index.js +46 -53
- package/engine/evm/injected.js +34 -42
- package/engine/evm/permit.js +129 -56
- package/engine/evm/prices.js +30 -45
- package/engine/evm/session.js +188 -189
- package/engine/mock/chain.js +17 -18
- package/engine/monero/address.js +33 -48
- package/engine/monero/halvesSweep.js +25 -43
- package/engine/monero/node.js +14 -15
- package/engine/monero/scan.js +82 -95
- package/engine/monero/swapKeys.js +30 -33
- package/engine/monero/unlock.js +17 -20
- package/engine/monero/wallet.js +32 -42
- package/engine/recovery/recoveryFile.js +154 -161
- package/engine/recovery/restore.js +50 -61
- package/engine/sdk/bridge.js +244 -234
- package/engine/ui/dom.js +4 -5
- package/engine/ui/walletModal.js +20 -31
- package/index.d.ts +26 -0
- package/package.json +1 -1
- package/src/adapters/evm-wallet.mjs +16 -0
- package/src/adapters/monero-wallet.mjs +1 -1
- package/src/adapters/monero.mjs +1 -1
- package/src/adapters/node-test-evm-wallet.mjs +5 -5
- package/src/codes.mjs +5 -0
- package/src/config.mjs +1 -1
- package/src/engine.mjs +4 -0
- package/src/index.mjs +16 -2
- package/src/legs/cow-spec.mjs +254 -0
- package/src/legs/cow.mjs +315 -0
- package/src/legs/evm.mjs +86 -2
- package/src/legs/kyber-spec.mjs +265 -0
- package/src/legs/kyber.mjs +425 -0
- package/src/legs/shape.mjs +35 -0
- package/src/legs/uniswaprelay-spec.mjs +541 -0
- package/src/legs/uniswaprelay.mjs +422 -0
- package/src/legs/uniswapx-spec.mjs +329 -0
- package/src/legs/uniswapx.mjs +362 -0
- package/src/quotes.mjs +23 -5
- package/src/swaps.mjs +18 -1
|
@@ -1,96 +1,92 @@
|
|
|
1
1
|
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/recovery/recoveryFile.js for the SDK package.
|
|
2
|
-
// Edit the source under www/js
|
|
3
|
-
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
2
|
+
// Edit the source under www/js and regenerate the mirror; a drift guard reddens on any difference.
|
|
4
3
|
// Everything below this header is identical to the source.
|
|
5
4
|
|
|
6
|
-
//
|
|
5
|
+
// The deal recovery file: real cryptography in the browser (WebCrypto).
|
|
7
6
|
//
|
|
8
|
-
// Litepaper §5:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// файл можно расшифровать только паролем, который знает пользователь.
|
|
7
|
+
// Litepaper §5: without a saved recovery file a deal is not funded; if the tab is lost, this file is the only way
|
|
8
|
+
// to claim the XMR or refund the ETH (including through a standalone client without the site). So it is real
|
|
9
|
+
// AES-GCM, not a stub: the file can be decrypted only with the password the user knows.
|
|
12
10
|
//
|
|
13
|
-
//
|
|
11
|
+
// File format (Ninsei-swap-<id>.json.enc):
|
|
14
12
|
// { v:2, alg:"AES-GCM-256/PBKDF2-SHA256", iter, salt, iv, ciphertext, meta:{id,side,createdAt} }
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// содержимое сделки это разные вещи.
|
|
13
|
+
// Inside ciphertext is the payload (see buildPayload), versioned separately: the envelope version (v) and the
|
|
14
|
+
// payload version (version) change independently - encryption and deal content are different things.
|
|
18
15
|
//
|
|
19
|
-
//
|
|
20
|
-
// v3:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// v2: mnemonic
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// (раньше в файле лежали и seedHex, и оба приватных ключа).
|
|
16
|
+
// WHAT IS IN THE PAYLOAD, AND WHY EXACTLY THIS
|
|
17
|
+
// v3: MY HALF of the spend key and MY HALF of the view key (spendHalf, viewHalf) plus the counterparty's PUBLIC
|
|
18
|
+
// points (otherSpendPoint, otherViewPoint). No full spend key exists for ANYONE: the address is a sum of halves,
|
|
19
|
+
// and only the holder of both can spend. The second half is given by the chain - hence the escrow address is in
|
|
20
|
+
// the file too: the sweep reads the revealed half from it. The counterparty's points are public (they are in the
|
|
21
|
+
// address and the order anyway), but without them the address cannot be assembled from the halves.
|
|
22
|
+
// v2: the mnemonic is the single source of keys: both the spend key and the view key derive from it. Keys are NOT
|
|
23
|
+
// duplicated in the payload: a copy of the secret in the file is one more way to lose it and one more reason for
|
|
24
|
+
// the fields to drift (the file used to carry both seedHex and the two private keys).
|
|
29
25
|
// publicSpendKey /
|
|
30
26
|
// publicViewKey /
|
|
31
|
-
// address
|
|
32
|
-
//
|
|
33
|
-
// restoreHeight
|
|
34
|
-
//
|
|
35
|
-
//
|
|
27
|
+
// address - for checking: if the mnemonic derives another address, the file is corrupt or substituted, and a
|
|
28
|
+
// wallet must not be restored from it.
|
|
29
|
+
// restoreHeight - the height to start the scan from. Without it the wallet scans Monero history from zero,
|
|
30
|
+
// which is tens of hours (measured ~35 blocks/s). The deal's creation height is a correct floor: the XMR lock
|
|
31
|
+
// cannot happen earlier.
|
|
36
32
|
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
33
|
+
// WHAT IS NOT IN THE FILE: the spend key in the clear (only inside the ciphertext), a card number, a withdrawal
|
|
34
|
+
// address, and anything not needed to complete or roll back the deal.
|
|
39
35
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
36
|
+
// THERE IS NO QUOTE KIND IN THE PAYLOAD, and the payload key set is not checked: a file issued earlier with an
|
|
37
|
+
// extra kind key is still read (the extra key just goes unread) and new files are issued without it. The format
|
|
38
|
+
// does not change: PAYLOAD_VERSION stays 3 - the quote kind never had to do with the file version.
|
|
43
39
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
40
|
+
// THE ENCRYPTION PARAMETERS ARE NAMED EXPLICITLY AND ARE NOT TAKEN FROM WEB-CRYPTO DEFAULTS.
|
|
41
|
+
// The tag length and the key length are part of the FORMAT, not an implementation detail: a file issued today must
|
|
42
|
+
// be readable years later, so both numbers stand in every call (encrypt/decrypt/deriveKey), live in frozen objects
|
|
43
|
+
// and were confirmed by MEASUREMENT: the tag length (ciphertext minus plaintext = 16 bytes) and the derived key
|
|
44
|
+
// length (256 bits - proven by decrypting with a key derived separately as 256 bits). The PBKDF2 iteration count
|
|
45
|
+
// is also part of the format, but it is DELIBERATELY changeable: it grows with time, and old files are then read
|
|
46
|
+
// by their own count (see ITER_MIN below).
|
|
51
47
|
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// HMAC-SHA256 -
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
48
|
+
//
|
|
49
|
+
// THE ITERATION COUNT: 600,000 - OWASP's recommendation for PBKDF2-HMAC-SHA256, i.e. when FIPS-140 requires
|
|
50
|
+
// a "work factor of 600,000 or more" with an internal HMAC-SHA-256 (the numbers come from GPU measurements on an
|
|
51
|
+
// RTX 4000, as of December 2022).
|
|
52
|
+
// WHY PBKDF2 AND NOT Argon2id/scrypt, WHICH OWASP RANKS HIGHER: a browser offers only what WebCrypto gives, and
|
|
53
|
+
// among password-hashing it has ONE - PBKDF2; Argon2id and scrypt are not in the standard. There is nothing to
|
|
54
|
+
// choose from, so at least the number correct for PBKDF2 matters.
|
|
55
|
+
// WHY 210,000 WAS WRONG: that number is in the OWASP table for PBKDF2-HMAC-SHA512 (220,000), while we use
|
|
56
|
+
// HMAC-SHA256 - the count was three times below the recommendation for our own algorithm.
|
|
57
|
+
// WHAT THIS DOES NOT GIVE, SO AS NOT TO BE MISLED: iterations raise the price of ONE guess (measured on the
|
|
58
|
+
// development machine: 210k - 109 ms, 600k - 283 ms; noticeably more on a weak device in a browser) but do not
|
|
59
|
+
// save a weak password: an offline brute force is bounded by the password ENTROPY, not by the count. The 12-char
|
|
60
|
+
// minimum remains the weak spot.
|
|
65
61
|
const ITER = 600_000;
|
|
66
|
-
//
|
|
67
|
-
//
|
|
62
|
+
// A FLOOR FOR FOREIGN FILES. The iteration count comes FROM THE FILE (otherwise it could not be raised without
|
|
63
|
+
// losing old files), so a weak value must meet a refusal: a file below the floor is not decrypted.
|
|
68
64
|
const ITER_MIN = 100_000;
|
|
69
65
|
const ITER_MAX = 10_000_000;
|
|
70
|
-
//
|
|
71
|
-
//
|
|
66
|
+
// The algorithm identifier is ONE string for the whole module: it used to be written twice (write and read), and a
|
|
67
|
+
// divergence between the copies would surface only on a live file.
|
|
72
68
|
const ALG_ID = "AES-GCM-256/PBKDF2-SHA256";
|
|
73
69
|
const AES = Object.freeze({ name: "AES-GCM", tagLength: 128, keyBits: 256 });
|
|
74
70
|
const KDF = Object.freeze({ name: "PBKDF2", hash: "SHA-256" });
|
|
75
71
|
const te = new TextEncoder();
|
|
76
72
|
const td = new TextDecoder();
|
|
77
73
|
|
|
78
|
-
export const FILE_MAGIC = "
|
|
74
|
+
export const FILE_MAGIC = "NINSEI-SWAP-RECOVERY";
|
|
79
75
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
76
|
+
// The envelope version (encryption) and the payload version (deal content) are different numbers.
|
|
77
|
+
// ENVELOPE VERSION: 1 - no AAD (all files issued before it was added), 2 - the header is authenticated (AAD).
|
|
78
|
+
// BOTH must be readable: people hold version-1 files and they must not be lost. The version is raised when the
|
|
79
|
+
// ENVELOPE itself changes, not the payload inside.
|
|
84
80
|
export const ENVELOPE_VERSION = 2;
|
|
85
81
|
const READABLE_ENVELOPE_VERSIONS = [1, 2];
|
|
86
82
|
export const PAYLOAD_VERSION = 3;
|
|
87
|
-
// v1 -
|
|
88
|
-
//
|
|
89
|
-
//
|
|
83
|
+
// v1 - the very first files (seedHex and both keys as separate fields), v2 - the mnemonic as the single source,
|
|
84
|
+
// v3 - HALVES. All three must be readable: people hold files of every version, and a recovery file is the only way
|
|
85
|
+
// to claim your XMR. Old versions must not be dropped.
|
|
90
86
|
const SUPPORTED_PAYLOAD_VERSIONS = [1, 2, 3];
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
87
|
+
// NETWORKS THE FILE READER KNOWS. The three standard ones plus fakechain - the official name of monerod's regtest
|
|
88
|
+
// mode: the CI contour node calls itself exactly that, and a file issued on such a network must be readable. The
|
|
89
|
+
// list is by name: a network outside it is rejected as unknown, and that stays.
|
|
94
90
|
const NETWORKS = ["mainnet", "stagenet", "testnet", "fakechain"];
|
|
95
91
|
|
|
96
92
|
function b64(bytes) {
|
|
@@ -101,20 +97,20 @@ function unb64(str) {
|
|
|
101
97
|
return Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
|
|
102
98
|
}
|
|
103
99
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// AAD:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
100
|
+
// THE ITERATION COUNT IS A PARAMETER, NOT A CALL CONSTANT. On encryption it is ITER, on reading it is the number
|
|
101
|
+
// FROM THE FILE: the format stores it precisely so the count can be raised without losing already issued files.
|
|
102
|
+
// Reading used to take the constant and ignore the iter field entirely - so raising the count would have silently
|
|
103
|
+
// broken all old files, and the field in the file would have been decoration.
|
|
104
|
+
// AAD: THE HEADER IS UNDER AUTHENTICATION, NOT ONLY THE CIPHERTEXT. AES-GCM checks the integrity of what is passed
|
|
105
|
+
// to it as additionalData. Without AAD (as in version 1) only the ciphertext was covered, while `meta` - the deal
|
|
106
|
+
// id, side, time, terms hash - sits beside it and could be edited without a trace, and the reader would take the
|
|
107
|
+
// substituted value. The string is built by both writer and reader FROM THE SAME FIELDS IN THE SAME ORDER: any
|
|
108
|
+
// field edit changes the string and decryption is rejected.
|
|
109
|
+
// WHY AN ARRAY, NOT AN OBJECT: key order in an object is a JSON implementation detail and an "identical" object
|
|
110
|
+
// can yield different text. And why ?? null: a missing field in the array would give undefined, which is another
|
|
111
|
+
// value.
|
|
112
|
+
// EXPORTED ON PURPOSE: the string is part of the FORMAT, and checks must build it with the same code, not their
|
|
113
|
+
// own copy (a copy would diverge from the format exactly where it costs most).
|
|
118
114
|
export function envelopeAad(envelope) {
|
|
119
115
|
const m = envelope.meta || {};
|
|
120
116
|
return JSON.stringify([
|
|
@@ -134,21 +130,21 @@ async function deriveKey(passphrase, salt, iterations) {
|
|
|
134
130
|
);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
133
|
+
// THE HEIGHT TO START THE RECOVERY SCAN FROM. A pure function: it goes nowhere, so it is checked without a node -
|
|
134
|
+
// and that matters, because this is exactly where the height used to get lost.
|
|
139
135
|
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
136
|
+
// WHY TWO SOURCES. The height was taken ONLY from the Monero node, and when the node did not answer, null was
|
|
137
|
+
// honestly written into the file. Recovery then scanned history from zero (tens of hours), although the exact deal
|
|
138
|
+
// height was at hand: the escrow is created by a transaction and its block is in the receipt. The node is someone
|
|
139
|
+
// else's service that may be down (a live proxy returned 502), while the deal is our own and already exists.
|
|
144
140
|
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
141
|
+
// TAKE THE SMALLER AND STEP BACK A BLOCK. The XMR arrival may land a block earlier than the recorded height, and a
|
|
142
|
+
// scan that starts below the money finds it; one that starts above never does. Hence Math.min and minus one, but
|
|
143
|
+
// never below one.
|
|
144
|
+
// THE THIRD SOURCE - THE APPLICATION. Old deal records have no birthHeight field (it appeared later), but the app
|
|
145
|
+
// resolved and stored the height when it registered the watch; the page gets it from its server. This is OUR
|
|
146
|
+
// source, so it does not depend on whether the Monero node answers now, and it is what saves already issued
|
|
147
|
+
// files: their record has no height at all.
|
|
152
148
|
export function scanStartHeight({ escrowBirthHeight = null, serverRestoreHeight = null, nodeHeight = null } = {}) {
|
|
153
149
|
const known = [escrowBirthHeight, serverRestoreHeight, nodeHeight]
|
|
154
150
|
.map((v) => Number(v))
|
|
@@ -157,21 +153,19 @@ export function scanStartHeight({ escrowBirthHeight = null, serverRestoreHeight
|
|
|
157
153
|
return Math.max(1, Math.min(...known) - 1);
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
156
|
+
// The payload: everything needed to complete or roll back the deal without the site. restoreHeight is passed by
|
|
157
|
+
// the caller - scanStartHeight puts it here, and the module does not go to the network itself so it can be tested
|
|
158
|
+
// without a node.
|
|
163
159
|
export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restoreHeight = null }) {
|
|
164
160
|
return {
|
|
165
161
|
magic: FILE_MAGIC,
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// тройка, у файла с мнемоникой двойка.
|
|
162
|
+
// THE VERSION REFLECTS THE CONTENT, NOT THE MODULE VERSION. It used to hold PAYLOAD_VERSION, so after halves
|
|
163
|
+
// appeared a file built from a mnemonic got number 3 - claiming content it did not have, and the check rightly
|
|
164
|
+
// rejected it. The number must tell the truth about the content: a halves file is three, a mnemonic file is two.
|
|
170
165
|
version: swapWallet && swapWallet.spendHalf ? 3 : 2,
|
|
171
166
|
swapId: swap.id,
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// Поле НЕОБЯЗАТЕЛЬНОЕ: у файлов, выпущенных раньше, его нет, и читаться они должны по-прежнему.
|
|
167
|
+
// The order terms hash FROM THE CONTRACT. It is the file's tie to the chain: knowing the terms, one can check
|
|
168
|
+
// the file belongs to this very order. The field is OPTIONAL: files issued earlier lack it and must still read.
|
|
175
169
|
termsHash: (swap.escrow && swap.escrow.termsHash) || null,
|
|
176
170
|
side: swap.side,
|
|
177
171
|
network: swap.network,
|
|
@@ -183,8 +177,8 @@ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restore
|
|
|
183
177
|
maker: swap.maker,
|
|
184
178
|
quote: { signature: swap.quoteSignature, nonce: swap.quoteSignature ? swap.quoteSignature.slice(-8) : null },
|
|
185
179
|
escrow: escrow || swap.escrow || null,
|
|
186
|
-
// v3 (
|
|
187
|
-
//
|
|
180
|
+
// v3 (halves) and v2 (mnemonic) are different content, incompatible: v3 has NO mnemonic, because no full spend
|
|
181
|
+
// key exists. What to write is decided by the wallet source.
|
|
188
182
|
moneroWallet: swapWallet
|
|
189
183
|
? swapWallet.spendHalf
|
|
190
184
|
? {
|
|
@@ -197,10 +191,10 @@ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restore
|
|
|
197
191
|
viewHalf: swapWallet.viewHalf,
|
|
198
192
|
otherSpendPoint: swapWallet.otherSpendPoint,
|
|
199
193
|
otherViewPoint: swapWallet.otherViewPoint,
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
194
|
+
// THE COUNTERPARTY'S VIEW HALF - and it is not a secret. The full VIEW key (the sum of two halves) lets
|
|
195
|
+
// one SEE incoming funds and not spend them; spending needs the sum of the SPEND halves. Without the
|
|
196
|
+
// other view half the wallet cannot find our XMR on chain: the spend halves alone are not enough for
|
|
197
|
+
// the scan, and Monero's view and spend keys are independent.
|
|
204
198
|
otherViewHalf: swapWallet.otherViewHalf || null,
|
|
205
199
|
escrowAddress: (escrow && escrow.address) || (swap.escrow && swap.escrow.address) || null,
|
|
206
200
|
restoreHeight: Number.isFinite(restoreHeight) ? restoreHeight : null,
|
|
@@ -218,54 +212,53 @@ export function buildPayload({ swap, swapWallet, escrow, receiveAddress, restore
|
|
|
218
212
|
}
|
|
219
213
|
: null,
|
|
220
214
|
deadlines: swap.timeline,
|
|
221
|
-
recoveryClient: "any standalone client implementing the
|
|
215
|
+
recoveryClient: "any standalone client implementing the Ninsei swap protocol (see litepaper §5.3)",
|
|
222
216
|
};
|
|
223
217
|
}
|
|
224
218
|
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// Секреты в тексте ошибок не появляются: мнемоника - это ключ траты.
|
|
219
|
+
// Payload validation AFTER decryption. A separate function, because garbage can decrypt too (the password
|
|
220
|
+
// matched but the content is wrong) and because checks must hit each field separately. It throws with a clear
|
|
221
|
+
// cause instead of failing deep inside wallet recovery. No secrets appear in error text: a mnemonic is a spend key.
|
|
229
222
|
export function validatePayload(payload) {
|
|
230
223
|
const fail = (m) => {
|
|
231
|
-
throw new Error("
|
|
224
|
+
throw new Error("Corrupt recovery file: " + m);
|
|
232
225
|
};
|
|
233
|
-
if (!payload || typeof payload !== "object") fail("
|
|
234
|
-
if (payload.magic !== FILE_MAGIC) fail("
|
|
226
|
+
if (!payload || typeof payload !== "object") fail("the content is not an object");
|
|
227
|
+
if (payload.magic !== FILE_MAGIC) fail("foreign file (no Ninsei mark)");
|
|
235
228
|
if (!SUPPORTED_PAYLOAD_VERSIONS.includes(payload.version)) {
|
|
236
|
-
fail(
|
|
229
|
+
fail(`payload version ${payload.version} is not supported (we support ${SUPPORTED_PAYLOAD_VERSIONS.join(", ")})`);
|
|
237
230
|
}
|
|
238
|
-
if (!payload.swapId) fail("
|
|
231
|
+
if (!payload.swapId) fail("no deal id");
|
|
239
232
|
const m = payload.moneroWallet;
|
|
240
|
-
if (!m || typeof m !== "object") fail("
|
|
233
|
+
if (!m || typeof m !== "object") fail("no Monero wallet data");
|
|
241
234
|
if (payload.version >= 3) {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
235
|
+
// HALVES. There is no mnemonic here and there must not be: no full spend key exists for either side, and a
|
|
236
|
+
// file that somehow contains one means the wallet was not built by the scheme.
|
|
244
237
|
for (const field of ["spendHalf", "viewHalf", "otherSpendPoint", "otherViewPoint", "otherViewHalf"]) {
|
|
245
|
-
if (!m[field]) fail("
|
|
238
|
+
if (!m[field]) fail("the halves file has no field " + field);
|
|
246
239
|
}
|
|
247
|
-
if (m.mnemonic) fail("
|
|
240
|
+
if (m.mnemonic) fail("the halves file contains a mnemonic - the wallet was not built by the scheme");
|
|
248
241
|
} else {
|
|
249
|
-
if (!m.mnemonic) fail("
|
|
242
|
+
if (!m.mnemonic) fail("no mnemonic - there is nothing that can restore the wallet");
|
|
250
243
|
const words = String(m.mnemonic).trim().split(/\s+/).length;
|
|
251
|
-
if (words !== 25 && words !== 13 && words !== 24) fail(
|
|
244
|
+
if (words !== 25 && words !== 13 && words !== 24) fail(`the mnemonic does not look like a Monero phrase (${words} words)`);
|
|
252
245
|
}
|
|
253
|
-
if (!m.address) fail("
|
|
246
|
+
if (!m.address) fail("no wallet address - nothing to check the restored one against");
|
|
254
247
|
const net = m.network || payload.moneroNetwork;
|
|
255
|
-
if (!NETWORKS.includes(net)) fail(
|
|
248
|
+
if (!NETWORKS.includes(net)) fail(`unknown network "${net}"`);
|
|
256
249
|
if (m.network && payload.moneroNetwork && m.network !== payload.moneroNetwork) {
|
|
257
|
-
fail(
|
|
250
|
+
fail(`the wallet network (${m.network}) does not match the deal network (${payload.moneroNetwork})`);
|
|
258
251
|
}
|
|
259
252
|
|
|
260
253
|
const warnings = [];
|
|
261
254
|
if (!Number.isFinite(m.restoreHeight) || m.restoreHeight <= 0) {
|
|
262
255
|
warnings.push(
|
|
263
|
-
"
|
|
264
|
-
"
|
|
256
|
+
"no restore height (restoreHeight): the wallet will scan history from zero, " +
|
|
257
|
+
"which is tens of hours - set the XMR lock height manually"
|
|
265
258
|
);
|
|
266
259
|
}
|
|
267
260
|
if (payload.version === 1) {
|
|
268
|
-
warnings.push("
|
|
261
|
+
warnings.push("an old-format file (v1): its keys were separate fields, the spend key is taken from the mnemonic");
|
|
269
262
|
}
|
|
270
263
|
return { warnings };
|
|
271
264
|
}
|
|
@@ -275,7 +268,7 @@ export async function encryptPayload(payload, passphrase) {
|
|
|
275
268
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
276
269
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
277
270
|
const key = await deriveKey(passphrase, salt, ITER);
|
|
278
|
-
//
|
|
271
|
+
// The header is built BEFORE encryption: it also serves as the AAD, so the ciphertext is tied to its content.
|
|
279
272
|
const meta = { id: payload.swapId, side: payload.side, createdAt: payload.createdAt, termsHash: payload.termsHash || null };
|
|
280
273
|
const header = { v: ENVELOPE_VERSION, alg: ALG_ID, iter: ITER, salt: b64(salt), iv: b64(iv) };
|
|
281
274
|
const ct = await crypto.subtle.encrypt(
|
|
@@ -286,9 +279,9 @@ export async function encryptPayload(payload, passphrase) {
|
|
|
286
279
|
return { ...header, ciphertext: b64(ct), meta };
|
|
287
280
|
}
|
|
288
281
|
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
282
|
+
// Decryption. AES-GCM errors (wrong password, altered ciphertext) arrive as OperationError with no detail - we
|
|
283
|
+
// turn them into a human message, else the user sees "operation failed" and cannot tell a wrong password from a
|
|
284
|
+
// corrupt file.
|
|
292
285
|
export async function decryptFile(fileObject, passphrase) {
|
|
293
286
|
if (!fileObject || typeof fileObject !== "object") throw new Error("Not a recovery file (expecting JSON)");
|
|
294
287
|
if (!READABLE_ENVELOPE_VERSIONS.includes(fileObject.v)) throw new Error(`Unsupported recovery file version: ${fileObject.v}`);
|
|
@@ -296,8 +289,8 @@ export async function decryptFile(fileObject, passphrase) {
|
|
|
296
289
|
for (const field of ["salt", "iv", "ciphertext"]) {
|
|
297
290
|
if (typeof fileObject[field] !== "string" || !fileObject[field]) throw new Error(`Recovery file is missing "${field}"`);
|
|
298
291
|
}
|
|
299
|
-
//
|
|
300
|
-
//
|
|
292
|
+
// THE ITERATION COUNT IS READ FROM THE FILE AND CHECKED. Below the floor - a refusal: a weak count must not lead
|
|
293
|
+
// to a quiet decryption. Above the ceiling - also a refusal: that is no longer a file but an attempt to occupy the CPU.
|
|
301
294
|
const iter = Number(fileObject.iter);
|
|
302
295
|
if (!Number.isInteger(iter) || iter < ITER_MIN) {
|
|
303
296
|
throw new Error(`Recovery file declares too few PBKDF2 iterations: ${fileObject.iter} (minimum ${ITER_MIN})`);
|
|
@@ -308,8 +301,8 @@ export async function decryptFile(fileObject, passphrase) {
|
|
|
308
301
|
const salt = unb64(fileObject.salt);
|
|
309
302
|
const iv = unb64(fileObject.iv);
|
|
310
303
|
const key = await deriveKey(passphrase, salt, iter);
|
|
311
|
-
// AAD
|
|
312
|
-
//
|
|
304
|
+
// AAD IS MIXED IN ONLY FROM VERSION 2. A version-1 file cannot take it: it was encrypted without AAD and would
|
|
305
|
+
// decrypt for nobody with one - so the "improvement" would have wiped every old file.
|
|
313
306
|
const additionalData = fileObject.v >= 2 ? te.encode(envelopeAad(fileObject)) : undefined;
|
|
314
307
|
const decryptAlg = additionalData
|
|
315
308
|
? { name: AES.name, iv, tagLength: AES.tagLength, additionalData }
|
|
@@ -318,28 +311,28 @@ export async function decryptFile(fileObject, passphrase) {
|
|
|
318
311
|
try {
|
|
319
312
|
pt = await crypto.subtle.decrypt(decryptAlg, key, unb64(fileObject.ciphertext));
|
|
320
313
|
} catch {
|
|
321
|
-
throw new Error("
|
|
314
|
+
throw new Error("Could not decrypt the file: wrong password or the file was altered");
|
|
322
315
|
}
|
|
323
316
|
let payload;
|
|
324
317
|
try {
|
|
325
318
|
payload = JSON.parse(td.decode(pt));
|
|
326
319
|
} catch {
|
|
327
|
-
throw new Error("
|
|
320
|
+
throw new Error("It decrypted, but the content is not JSON - the file is corrupt");
|
|
328
321
|
}
|
|
329
322
|
validatePayload(payload);
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
323
|
+
// IN VERSION 1 THE HEADER IS NOT AUTHENTICATED, SO WE CHECK IT AGAINST THE CONTENT. The meta of an old file can
|
|
324
|
+
// be substituted (no AAD), but it cannot disagree with the data inside the ciphertext: the deal id must match.
|
|
325
|
+
// For version 2 this is already redundant - AAD checks it.
|
|
333
326
|
if (fileObject.meta && fileObject.meta.id != null && payload.swapId != null &&
|
|
334
327
|
String(fileObject.meta.id) !== String(payload.swapId)) {
|
|
335
|
-
throw new Error("
|
|
328
|
+
throw new Error("The deal id in the header does not match the file content: the file was altered");
|
|
336
329
|
}
|
|
337
330
|
return payload;
|
|
338
331
|
}
|
|
339
332
|
|
|
340
|
-
// BigInt
|
|
341
|
-
//
|
|
342
|
-
//
|
|
333
|
+
// BigInt DOES NOT SERIALISE IN JSON, and key halves are BigInt: JSON.stringify FAILS on them and the recovery file
|
|
334
|
+
// is never issued. We turn BigInt into a hex string - same meaning, serialisable. The same handler sits in state
|
|
335
|
+
// writing (core/store.js): it is the same serialisation boundary.
|
|
343
336
|
const bigintSafe = (key, value) => (typeof value === "bigint" ? "0x" + value.toString(16) : value);
|
|
344
337
|
export async function createRecoveryFile(payload, passphrase) {
|
|
345
338
|
const file = await encryptPayload(payload, passphrase);
|
|
@@ -347,20 +340,20 @@ export async function createRecoveryFile(payload, passphrase) {
|
|
|
347
340
|
}
|
|
348
341
|
|
|
349
342
|
export function recoveryFileName(swapId) {
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
343
|
+
// The file name is lowercase and tied to the swap: it shows which deal the file belongs to, and it can be
|
|
344
|
+
// checked against the id on the server (same there). It used to be "Ninsei-swap-<id>.json.enc": a capital letter
|
|
345
|
+
// and no tie to a specific swap when there is no id yet.
|
|
346
|
+
// If there is no id yet (the file is downloaded before the swap is created) the name is FIXED, with no random tail.
|
|
354
347
|
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
348
|
+
// There used to be a random tail here: it stopped files overwriting each other, but in practice every click of
|
|
349
|
+
// "Download recovery file" produced a NEW name and the downloads filled with a heap of look-alike files of
|
|
350
|
+
// unclear origin. That is exactly what looked like "a different file every time". Now the name is stable and a
|
|
351
|
+
// repeated download is marked (1) by the browser - clearer than a new random string. The name matches the one
|
|
352
|
+
// already used on the server for an unfinished deal.
|
|
360
353
|
const raw = String(swapId || "").trim();
|
|
361
354
|
const known = raw && raw !== "pending" ? raw : "pending";
|
|
362
|
-
//
|
|
363
|
-
//
|
|
355
|
+
// Lowercase and safe characters only: the name goes to a filesystem, where case and special characters behave
|
|
356
|
+
// differently on Windows, Linux and macOS.
|
|
364
357
|
const safe = known.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
365
|
-
return `
|
|
358
|
+
return `ninsei-${safe}.json.enc`;
|
|
366
359
|
}
|