175 lines
6.7 KiB
JavaScript
175 lines
6.7 KiB
JavaScript
|
|
// The browser wallet against the PHP one.
|
||
|
|
//
|
||
|
|
// Sirius Press has two independent implementations of the same cryptography:
|
||
|
|
// PHP on the server, which verifies, and JavaScript in the page, which signs.
|
||
|
|
// If they disagree by one byte, nobody can log in — and the error will look
|
||
|
|
// like a rejected password rather than a hash mismatch, which is a miserable
|
||
|
|
// thing to debug at three in the morning.
|
||
|
|
//
|
||
|
|
// So this compares them directly. Every vector is the output of the other
|
||
|
|
// implementation, not of this one.
|
||
|
|
//
|
||
|
|
// node tests/interop.mjs
|
||
|
|
//
|
||
|
|
// Needs no dependencies: the wallet is loaded as plain source, with `window`
|
||
|
|
// pointed at the Node global so WebCrypto is found where the browser puts it.
|
||
|
|
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import { fileURLToPath } from "node:url";
|
||
|
|
import { dirname, join } from "node:path";
|
||
|
|
|
||
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
||
|
|
const assets = join(here, "..", "plugins", "sirius-press-auth", "assets");
|
||
|
|
|
||
|
|
globalThis.window = globalThis;
|
||
|
|
new Function(readFileSync(join(assets, "bip39-en.js"), "utf8"))();
|
||
|
|
new Function(readFileSync(join(assets, "wallet.js"), "utf8"))();
|
||
|
|
|
||
|
|
const W = window.SiriusWallet;
|
||
|
|
const I = W._internals;
|
||
|
|
const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||
|
|
|
||
|
|
let passed = 0;
|
||
|
|
let failed = 0;
|
||
|
|
|
||
|
|
function is(actual, expected, what) {
|
||
|
|
if (actual === expected) {
|
||
|
|
passed++;
|
||
|
|
console.log(` ok ${what}`);
|
||
|
|
} else {
|
||
|
|
failed++;
|
||
|
|
console.log(` FAIL ${what}`);
|
||
|
|
console.log(` expected: ${expected}`);
|
||
|
|
console.log(` actual: ${actual}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ok(condition, what) {
|
||
|
|
is(Boolean(condition), true, what);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- RIPEMD-160, against the published vectors -------------------------------
|
||
|
|
// WebCrypto has no RIPEMD-160, so the wallet carries its own. These are the
|
||
|
|
// reference values from the algorithm's own specification.
|
||
|
|
|
||
|
|
console.log("\n RIPEMD-160");
|
||
|
|
const enc = new TextEncoder();
|
||
|
|
is(hex(I.ripemd160(new Uint8Array(0))), "9c1185a5c5e9fc54612808977ee8f548b2258d31", 'the empty string');
|
||
|
|
is(hex(I.ripemd160(enc.encode("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", '"abc"');
|
||
|
|
is(
|
||
|
|
hex(I.ripemd160(enc.encode("message digest"))),
|
||
|
|
"5d0689ef49d2fae572b881b123a85ffa21595f36",
|
||
|
|
'"message digest"',
|
||
|
|
);
|
||
|
|
is(
|
||
|
|
hex(I.ripemd160(enc.encode("abcdefghijklmnopqrstuvwxyz"))),
|
||
|
|
"f71c27109c692c1b56bbdceb5b9d2865b3708dbc",
|
||
|
|
"the lowercase alphabet",
|
||
|
|
);
|
||
|
|
// Longer than one 64-byte block, which exercises the padding and the length
|
||
|
|
// field — the parts a single-block test never reaches.
|
||
|
|
is(
|
||
|
|
hex(I.ripemd160(enc.encode("1234567890".repeat(8)))),
|
||
|
|
"9b752e45573d4b39f4dbd3323cab82bf63326bfb",
|
||
|
|
"eighty bytes, spanning two blocks",
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- secp256k1 ---------------------------------------------------------------
|
||
|
|
|
||
|
|
console.log("\n secp256k1");
|
||
|
|
const privOne = Uint8Array.from([...new Array(31).fill(0), 1]);
|
||
|
|
is(
|
||
|
|
hex(await I.publicKey(privOne)),
|
||
|
|
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||
|
|
"the public key for private key 1",
|
||
|
|
);
|
||
|
|
is(
|
||
|
|
await W.addressFromPublicKey(await I.publicKey(privOne), "bitcoincash"),
|
||
|
|
"bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
|
||
|
|
"the mainnet address for private key 1",
|
||
|
|
);
|
||
|
|
|
||
|
|
// --- derivation, against libauth ---------------------------------------------
|
||
|
|
// This phrase, and the address below it, came out of BuiltInWallet in
|
||
|
|
// Argus/src/lib/wallet-web.js — the wallet the Sirius portal runs.
|
||
|
|
|
||
|
|
console.log("\n BIP-39 and BIP-32");
|
||
|
|
const phrase = "trash key flip dawn impulse float medal rain sell hand neither hub";
|
||
|
|
const wallet = await W.fromPhrase(phrase, { prefix: "bchtest" });
|
||
|
|
is(wallet.address, "bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy", "derives the portal wallet's address");
|
||
|
|
|
||
|
|
const messy = await W.fromPhrase(" Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", {
|
||
|
|
prefix: "bchtest",
|
||
|
|
});
|
||
|
|
is(messy.address, wallet.address, "spacing and capitals do not change the wallet");
|
||
|
|
|
||
|
|
// --- signing, against PHP -----------------------------------------------------
|
||
|
|
// The signature below was produced by SP_Message::sign() in PHP. Both sides run
|
||
|
|
// RFC 6979, so the same key and message give the same bytes — an equality this
|
||
|
|
// strict is only possible because neither implementation uses randomness.
|
||
|
|
|
||
|
|
console.log("\n signatures match PHP");
|
||
|
|
const loginMessage = [
|
||
|
|
"SIRIUS-PRESS-LOGIN1",
|
||
|
|
"https://example.bch",
|
||
|
|
wallet.address,
|
||
|
|
"abc123",
|
||
|
|
"1758412800000",
|
||
|
|
].join("\n");
|
||
|
|
|
||
|
|
is(
|
||
|
|
hex(await I.messageDigest(loginMessage)),
|
||
|
|
"98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19",
|
||
|
|
"the BIP-137 digest matches PHP and the Theseus wallet",
|
||
|
|
);
|
||
|
|
is(
|
||
|
|
await wallet.sign(loginMessage),
|
||
|
|
"IGr9FSoDInLUr+iKZGw8w5LPyi/JsZAU6mFvGc80uQReP3IQZU7KMODzwS78FkRE57sh2osmWq1sVHZam81PTkY=",
|
||
|
|
"the signature is byte-identical to the one PHP produces",
|
||
|
|
);
|
||
|
|
is(await wallet.sign(loginMessage), await wallet.sign(loginMessage), "signing is deterministic");
|
||
|
|
|
||
|
|
// --- the upload envelope ------------------------------------------------------
|
||
|
|
// Manual-mode publishing signs a raw digest rather than a text message. The
|
||
|
|
// expected value is SP_Message::site_digest() from PHP.
|
||
|
|
|
||
|
|
console.log("\n BNS-SITE1 upload envelope");
|
||
|
|
const body = "<h1>hi</h1>";
|
||
|
|
const bodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(body))));
|
||
|
|
const envelope = `BNS-SITE1\nexample.bch\nindex.html\n${bodyHash}\n1758412800000`;
|
||
|
|
const siteDigest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(envelope)));
|
||
|
|
is(
|
||
|
|
hex(siteDigest),
|
||
|
|
"a71c4db0bd7268cd2b2103e2c0753ad37b046b203a4bf1ba7e84271b816d10de",
|
||
|
|
"the upload digest matches PHP",
|
||
|
|
);
|
||
|
|
const rawSig = await wallet.signRaw(siteDigest);
|
||
|
|
is(atob(rawSig).length, 65, "signRaw produces 65 bytes");
|
||
|
|
|
||
|
|
// --- phrase generation --------------------------------------------------------
|
||
|
|
|
||
|
|
console.log("\n phrase handling");
|
||
|
|
const generated = await W.generatePhrase(12);
|
||
|
|
is(generated.split(" ").length, 12, "a generated phrase has twelve words");
|
||
|
|
ok(W.validatePhrase(generated).ok, "a generated phrase validates");
|
||
|
|
const generated24 = await W.generatePhrase(24);
|
||
|
|
is(generated24.split(" ").length, 24, "twenty-four words on request");
|
||
|
|
ok(W.validatePhrase(generated24).ok, "the long phrase validates too");
|
||
|
|
ok((await W.fromPhrase(generated, { prefix: "bchtest" })).address.startsWith("bchtest:"), "and opens a wallet");
|
||
|
|
|
||
|
|
ok(!W.validatePhrase("").ok, "an empty phrase is refused");
|
||
|
|
ok(!W.validatePhrase("one two three").ok, "a three-word phrase is refused");
|
||
|
|
is(
|
||
|
|
W.validatePhrase("abandon abandon recieve abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||
|
|
.error,
|
||
|
|
"Word 3, “recieve”, is not a recovery-phrase word.",
|
||
|
|
"a misspelled word is named and numbered",
|
||
|
|
);
|
||
|
|
|
||
|
|
console.log("");
|
||
|
|
if (failed > 0) {
|
||
|
|
console.log(` ${passed + failed} checks, ${failed} FAILED\n`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
console.log(` ${passed + failed} checks, all passed\n`);
|