@oneaddress/setup 2.1.1 → 2.1.2
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/dist/index.js +194 -28
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
|
|
|
856
856
|
var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
|
|
857
857
|
var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
|
|
858
858
|
var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
|
|
859
|
-
var WIZARD_VERSION = true ? "2.1.
|
|
859
|
+
var WIZARD_VERSION = true ? "2.1.2" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -964,8 +964,8 @@ data.db-shm
|
|
|
964
964
|
"private": true,
|
|
965
965
|
"scripts": {
|
|
966
966
|
"dev": "tsx watch src/index.ts",
|
|
967
|
-
"start": "tsx src/index.ts",
|
|
968
|
-
"headless": "tsx src/index.ts -- --headless",
|
|
967
|
+
"start": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts",
|
|
968
|
+
"headless": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts -- --headless",
|
|
969
969
|
"build": "tsup src/index.ts --format esm --no-dts --outDir dist",
|
|
970
970
|
"type-check": "tsc --noEmit",
|
|
971
971
|
"test": "tsx scripts/test.ts"
|
|
@@ -1081,6 +1081,7 @@ import {
|
|
|
1081
1081
|
encryptField,
|
|
1082
1082
|
isEncrypted,
|
|
1083
1083
|
newSalt,
|
|
1084
|
+
PassphraseRequiredError,
|
|
1084
1085
|
verifierMatches,
|
|
1085
1086
|
WrongPassphraseError,
|
|
1086
1087
|
type VaultKeys,
|
|
@@ -1128,14 +1129,36 @@ function saltForThisDatabase(): Buffer {
|
|
|
1128
1129
|
}
|
|
1129
1130
|
|
|
1130
1131
|
function openKeys(): VaultKeys | null {
|
|
1132
|
+
// THE VERIFIER IS READ FIRST, and the order is the whole fix.
|
|
1133
|
+
//
|
|
1134
|
+
// This used to return null the moment no passphrase was set, without ever
|
|
1135
|
+
// asking whether the database was encrypted. An encrypted store opened with
|
|
1136
|
+
// no passphrase therefore ran IN THE CLEAR over the top of itself: it could
|
|
1137
|
+
// not decrypt the existing rows, so their blind indexes never matched, so it
|
|
1138
|
+
// seeded a SECOND copy of the roster in plaintext beside the first. The next
|
|
1139
|
+
// unlock then tried to converge both copies onto one key and died on a
|
|
1140
|
+
// UNIQUE constraint, permanently, with the correct passphrase in hand.
|
|
1141
|
+
//
|
|
1142
|
+
// Reported by a partner on 2.1.1 as "it is locking me out", and the lockout
|
|
1143
|
+
// was the last step of three. Two things were wrong and the quiet one was
|
|
1144
|
+
// worse: plaintext customer records written into a database its owner had
|
|
1145
|
+
// encrypted.
|
|
1146
|
+
const stored = readMeta('verifier');
|
|
1131
1147
|
const passphrase = process.env.ONEADDRESS_DB_PASSPHRASE;
|
|
1132
|
-
|
|
1148
|
+
|
|
1149
|
+
if (!passphrase || !passphrase.trim()) {
|
|
1150
|
+
// Never silently. A database with no verifier has never been locked and
|
|
1151
|
+
// running it in the clear is the documented, chosen behaviour; one WITH a
|
|
1152
|
+
// verifier is somebody's encrypted store and this is not the process that
|
|
1153
|
+
// gets to open it.
|
|
1154
|
+
if (stored !== null) throw new PassphraseRequiredError();
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1133
1157
|
|
|
1134
1158
|
const keys = deriveKeys(passphrase, saltForThisDatabase());
|
|
1135
1159
|
|
|
1136
1160
|
// Checked BEFORE anything is written. A mistyped passphrase must not be able
|
|
1137
1161
|
// to write a single row of ciphertext that nothing can ever read back.
|
|
1138
|
-
const stored = readMeta('verifier');
|
|
1139
1162
|
if (stored === null) writeMeta('verifier', buildVerifier(keys));
|
|
1140
1163
|
else if (!verifierMatches(keys, stored)) throw new WrongPassphraseError();
|
|
1141
1164
|
|
|
@@ -1182,7 +1205,16 @@ export function once(column: string, value: string | null): string | null {
|
|
|
1182
1205
|
return isEncrypted(value) ? value : encryptField(keys, column, value);
|
|
1183
1206
|
}
|
|
1184
1207
|
|
|
1185
|
-
export { WrongPassphraseError };
|
|
1208
|
+
export { PassphraseRequiredError, WrongPassphraseError };
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Is this stored value ciphertext? Exported for the repair pass in \`store.ts\`.
|
|
1212
|
+
*
|
|
1213
|
+
* The repair has to tell an encrypted row from a plaintext one written beside
|
|
1214
|
+
* it by a run that had no passphrase, and that is the only question that
|
|
1215
|
+
* separates them.
|
|
1216
|
+
*/
|
|
1217
|
+
export { isEncrypted };
|
|
1186
1218
|
export default db;
|
|
1187
1219
|
`
|
|
1188
1220
|
},
|
|
@@ -1363,6 +1395,25 @@ export class WrongPassphraseError extends Error {
|
|
|
1363
1395
|
}
|
|
1364
1396
|
}
|
|
1365
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* The database is encrypted and nobody supplied a passphrase.
|
|
1400
|
+
*
|
|
1401
|
+
* NAMED SEPARATELY FROM \\\`WrongPassphraseError\\\` because the remedy is different:
|
|
1402
|
+
* one means try again, the other means you have not been asked yet. Before this
|
|
1403
|
+
* existed the case had no error at all - opening an encrypted database with no
|
|
1404
|
+
* passphrase returned no keys and the receiver carried on IN THE CLEAR over the
|
|
1405
|
+
* top of it, which cost a partner their database. See the refusal in db.ts.
|
|
1406
|
+
*/
|
|
1407
|
+
export class PassphraseRequiredError extends Error {
|
|
1408
|
+
constructor() {
|
|
1409
|
+
super(
|
|
1410
|
+
'This database is encrypted. Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` ' +
|
|
1411
|
+
'in a terminal to be asked for it.',
|
|
1412
|
+
);
|
|
1413
|
+
this.name = 'PassphraseRequiredError';
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1366
1417
|
export interface VaultKeys {
|
|
1367
1418
|
/** AES-256-GCM key for the personal columns. */
|
|
1368
1419
|
cipherKey: Buffer;
|
|
@@ -1814,7 +1865,7 @@ export function notePreviousAddress(prev: Record<string, unknown>): void {
|
|
|
1814
1865
|
* \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
|
|
1815
1866
|
*/
|
|
1816
1867
|
import { printBanner } from './brand.js';
|
|
1817
|
-
import { WrongPassphraseError } from './vault.js';
|
|
1868
|
+
import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
|
|
1818
1869
|
|
|
1819
1870
|
/**
|
|
1820
1871
|
* No dashboard without a terminal to draw it in.
|
|
@@ -1878,25 +1929,79 @@ async function databaseIsLocked(): Promise<boolean> {
|
|
|
1878
1929
|
* hook is not available, being asked in the clear beats not being asked.
|
|
1879
1930
|
*/
|
|
1880
1931
|
async function ask(prompt: string): Promise<string> {
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1932
|
+
process.stdout.write(prompt);
|
|
1933
|
+
|
|
1934
|
+
// NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
|
|
1935
|
+
// mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
|
|
1936
|
+
// by driving a real terminal and reading what came back. The first filtered
|
|
1937
|
+
// on whether the chunk contained the prompt, not knowing readline repaints
|
|
1938
|
+
// prompt and input together on every keystroke, so the condition was always
|
|
1939
|
+
// true. The second repainted the line and still leaked, because the echo was
|
|
1940
|
+
// never coming from that hook at all.
|
|
1941
|
+
//
|
|
1942
|
+
// Reading the keys directly removes the guessing. Raw mode turns the
|
|
1943
|
+
// terminal's own echo OFF, so the ONLY thing that can reach the screen is
|
|
1944
|
+
// what is written below: one asterisk per character, which is what a partner
|
|
1945
|
+
// asked for and what every other passphrase prompt does.
|
|
1946
|
+
const stdin = process.stdin;
|
|
1947
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
1948
|
+
// No terminal to control. Being asked in the clear beats not being asked,
|
|
1949
|
+
// and this path is only reached where nothing is watching anyway.
|
|
1950
|
+
const { createInterface } = await import('node:readline/promises');
|
|
1951
|
+
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
1952
|
+
try {
|
|
1953
|
+
const answer = await rl.question('');
|
|
1954
|
+
return answer.trim();
|
|
1955
|
+
} finally { rl.close(); }
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const wasRaw = stdin.isRaw === true;
|
|
1959
|
+
stdin.setRawMode(true);
|
|
1960
|
+
stdin.resume();
|
|
1961
|
+
stdin.setEncoding('utf8');
|
|
1962
|
+
|
|
1963
|
+
return new Promise<string>((resolve) => {
|
|
1964
|
+
let typed = '';
|
|
1965
|
+
const restore = (): void => {
|
|
1966
|
+
stdin.removeListener('data', onData);
|
|
1967
|
+
stdin.setRawMode(wasRaw);
|
|
1968
|
+
stdin.pause();
|
|
1890
1969
|
};
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1970
|
+
const onData = (chunk: string): void => {
|
|
1971
|
+
for (const ch of chunk) {
|
|
1972
|
+
if (ch === '\\r' || ch === '\\n') {
|
|
1973
|
+
restore();
|
|
1974
|
+
process.stdout.write('\\n');
|
|
1975
|
+
resolve(typed.trim());
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (ch === '\\u0003') { // Ctrl+C
|
|
1979
|
+
restore();
|
|
1980
|
+
process.stdout.write('\\n');
|
|
1981
|
+
process.exit(130);
|
|
1982
|
+
}
|
|
1983
|
+
if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
|
|
1984
|
+
restore();
|
|
1985
|
+
process.stdout.write('\\n');
|
|
1986
|
+
resolve(typed.trim());
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
if (ch === '\\u007f' || ch === '\\b') {
|
|
1990
|
+
// Backspace has to move the asterisks too, or the mask stops matching
|
|
1991
|
+
// what is actually in the buffer and the count misleads.
|
|
1992
|
+
if (typed.length > 0) {
|
|
1993
|
+
typed = typed.slice(0, -1);
|
|
1994
|
+
process.stdout.write('\\b \\b');
|
|
1995
|
+
}
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
if (ch < ' ') continue; // ignore the rest of the control range
|
|
1999
|
+
typed += ch;
|
|
2000
|
+
process.stdout.write('*');
|
|
2001
|
+
}
|
|
2002
|
+
};
|
|
2003
|
+
stdin.on('data', onData);
|
|
2004
|
+
});
|
|
1900
2005
|
}
|
|
1901
2006
|
|
|
1902
2007
|
/**
|
|
@@ -1927,7 +2032,17 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
1927
2032
|
if (headless || !process.stdin.isTTY) return null;
|
|
1928
2033
|
|
|
1929
2034
|
if (await databaseIsLocked()) {
|
|
1930
|
-
|
|
2035
|
+
// NAMES THE WAY OUT, because a partner can arrive here having never
|
|
2036
|
+
// knowingly set a passphrase. Versions before 2.0.1 asked for one with a
|
|
2037
|
+
// prompt that did not say whether it was creating or checking, so a
|
|
2038
|
+
// database can carry a verifier its owner does not remember agreeing to.
|
|
2039
|
+
// Reported by exactly that partner: "there was no opportunity to set a
|
|
2040
|
+
// passphrase". Without this line the only options are guess or search the
|
|
2041
|
+
// internet, and the answer is one command.
|
|
2042
|
+
process.stdout.write('\\n This customer database is encrypted.\\n');
|
|
2043
|
+
process.stdout.write(' If you do not know the passphrase, delete data.db and start\\n');
|
|
2044
|
+
process.stdout.write(' again: it holds your demo roster and test dispatches, nothing\\n');
|
|
2045
|
+
process.stdout.write(' OneAddress needs.\\n\\n');
|
|
1931
2046
|
const answer = await ask(' Passphrase to unlock: ');
|
|
1932
2047
|
return answer || null;
|
|
1933
2048
|
}
|
|
@@ -1967,6 +2082,15 @@ async function main(): Promise<void> {
|
|
|
1967
2082
|
const server = await import('./server.js');
|
|
1968
2083
|
config = { partnerName: server.PARTNER_NAME, port: server.PORT };
|
|
1969
2084
|
} catch (err) {
|
|
2085
|
+
if (err instanceof PassphraseRequiredError) {
|
|
2086
|
+
// Reached when nobody could be asked: a service unit, a piped stdin, or
|
|
2087
|
+
// the setup wizard's own health-check autostart. Before the refusal in
|
|
2088
|
+
// db.ts this case ran in the clear and corrupted the database instead.
|
|
2089
|
+
console.error('\\n This database is encrypted and no passphrase was supplied.');
|
|
2090
|
+
console.error(' Run \`npm start\` in a terminal to be asked for it, or set');
|
|
2091
|
+
console.error(' ONEADDRESS_DB_PASSPHRASE for an unattended start.\\n');
|
|
2092
|
+
process.exit(1);
|
|
2093
|
+
}
|
|
1970
2094
|
if (err instanceof WrongPassphraseError) {
|
|
1971
2095
|
// Named for what it is. Without this the first symptom is a decryption
|
|
1972
2096
|
// error on a live dispatch, which reads as a corrupt database and sends
|
|
@@ -2117,7 +2241,7 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
|
|
|
2117
2241
|
import { readFileSync } from 'node:fs';
|
|
2118
2242
|
import { join } from 'node:path';
|
|
2119
2243
|
import { report } from './report.js';
|
|
2120
|
-
import db, { accountKey, dec, enc, encrypted, once } from './db.js';
|
|
2244
|
+
import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
|
|
2121
2245
|
|
|
2122
2246
|
export type Address = Record<string, unknown>;
|
|
2123
2247
|
|
|
@@ -2178,6 +2302,48 @@ ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
|
|
|
2178
2302
|
// pass produces the same key rather than hashing a ciphertext and orphaning the
|
|
2179
2303
|
// row. A row orphaned that way still reads fine in a listing and is invisible
|
|
2180
2304
|
// to every dispatch, which is the worst kind of broken.
|
|
2305
|
+
// REPAIR FIRST, because a database can already be in the state the refusal in
|
|
2306
|
+
// db.ts now prevents. Before that refusal existed, a run with no passphrase
|
|
2307
|
+
// against an encrypted store could not decrypt the roster, so its blind indexes
|
|
2308
|
+
// never matched and it seeded a SECOND copy of every customer in plaintext. The
|
|
2309
|
+
// relabel below then maps both copies onto one key and dies on UNIQUE, with the
|
|
2310
|
+
// correct passphrase in hand and no way forward but deleting the file.
|
|
2311
|
+
//
|
|
2312
|
+
// So the plaintext twin is dropped. WHICH ONE GOES IS NOT A GUESS: the
|
|
2313
|
+
// encrypted row is the one the owner's passphrase wrote, the plaintext row was
|
|
2314
|
+
// written by a process that could not read it. Only the second is discarded,
|
|
2315
|
+
// and only when an encrypted row for the same account exists.
|
|
2316
|
+
//
|
|
2317
|
+
// Scoped to an encrypted database. With no keys there is nothing to compare,
|
|
2318
|
+
// every row is plaintext, and there is no duplicate to find.
|
|
2319
|
+
if (encrypted) {
|
|
2320
|
+
const all = db.prepare('SELECT rowid AS rid, account_number FROM customers').all() as Array<
|
|
2321
|
+
{ rid: number; account_number: string }
|
|
2322
|
+
>;
|
|
2323
|
+
const seen = new Map<string, boolean>(); // plain account -> has an encrypted row
|
|
2324
|
+
for (const r of all) {
|
|
2325
|
+
if (isEncrypted(r.account_number)) {
|
|
2326
|
+
const plain = dec('account_number', r.account_number);
|
|
2327
|
+
if (plain) seen.set(plain, true);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
const drop = db.prepare('DELETE FROM customers WHERE rowid = ?');
|
|
2331
|
+
let dropped = 0;
|
|
2332
|
+
for (const r of all) {
|
|
2333
|
+
if (isEncrypted(r.account_number)) continue;
|
|
2334
|
+
if (!seen.get(r.account_number)) continue;
|
|
2335
|
+
drop.run(r.rid);
|
|
2336
|
+
dropped += 1;
|
|
2337
|
+
}
|
|
2338
|
+
if (dropped > 0) {
|
|
2339
|
+
// Said out loud. A silent repair of somebody's customer table is the kind
|
|
2340
|
+
// of thing they should hear about from us rather than notice later.
|
|
2341
|
+
report.warn(
|
|
2342
|
+
\`[store] repaired \${dropped} plaintext duplicate customer row(s) left by a run with no passphrase\`,
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2181
2347
|
{
|
|
2182
2348
|
const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
|
|
2183
2349
|
{ rid: number; account_number: string; name: string; address: string }
|
|
@@ -7990,7 +8156,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
7990
8156
|
|
|
7991
8157
|
// src/register.ts
|
|
7992
8158
|
var import_node_crypto = require("crypto");
|
|
7993
|
-
var PKG_VERSION = true ? "2.1.
|
|
8159
|
+
var PKG_VERSION = true ? "2.1.2" : "dev";
|
|
7994
8160
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
7995
8161
|
function hmacSha256(secret, message) {
|
|
7996
8162
|
return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
|