Compare commits
1 Commits
2c1f724545
...
c951028837
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c951028837 |
@@ -1144,8 +1144,7 @@ indexes it as a real token transfer.
|
||||
it. AutistMask hides transactions below a configurable dust threshold
|
||||
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
|
||||
dust while low enough to preserve any transfer a user would plausibly care
|
||||
about. The threshold is user-configurable in Settings; a threshold of `0`
|
||||
hides nothing, exactly as clearing the checkbox does.
|
||||
about. The threshold is user-configurable in Settings.
|
||||
|
||||
- **User-configurable**: All of the above filters (known symbol verification,
|
||||
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
||||
@@ -1226,8 +1225,8 @@ Currently supported:
|
||||
|
||||
### Testing
|
||||
|
||||
- [x] Tests for mnemonic generation and address derivation
|
||||
- [x] Tests for xpub derivation and child address generation
|
||||
- [ ] Tests for mnemonic generation and address derivation
|
||||
- [ ] Tests for xpub derivation and child address generation
|
||||
- [ ] Test on Firefox (Manifest V2)
|
||||
|
||||
### Scam List
|
||||
|
||||
8
TODO.md
8
TODO.md
@@ -44,11 +44,6 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
|
||||
falling back to the 100,000 gwei default, and every address comparison in
|
||||
`src/shared/transactions.js` goes through one case-normalising helper so a
|
||||
checksummed genuine contract is no longer read as a spoof
|
||||
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
|
||||
- 2026-08-11: Password-gated recovery phrase display for HD wallets, reached
|
||||
from the wallet row in Settings, wiped on leaving the screen and excluded from
|
||||
the views the popup can reopen onto
|
||||
@@ -63,9 +58,6 @@ undefined identifiers, which is how
|
||||
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
|
||||
cannot enumerate `dist/`
|
||||
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
|
||||
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
|
||||
derivation in `wallet.js` and the Argon2id vault in `vault.js`
|
||||
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
|
||||
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
|
||||
attribution, token-display rule, navigation model
|
||||
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
|
||||
|
||||
@@ -323,17 +323,11 @@ function init(ctx) {
|
||||
|
||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||
$("settings-dust-threshold").addEventListener("change", async () => {
|
||||
const raw = $("settings-dust-threshold").value.trim();
|
||||
const val = Number(raw);
|
||||
// 0 is accepted and means "hide nothing". Empty, negative,
|
||||
// fractional and non-numeric input is rejected outright rather than
|
||||
// coerced, and the field is put back to the stored threshold so it
|
||||
// never shows a value the wallet is not using.
|
||||
if (raw !== "" && Number.isInteger(val) && val >= 0) {
|
||||
const val = parseInt($("settings-dust-threshold").value, 10);
|
||||
if (!isNaN(val) && val >= 0) {
|
||||
state.dustThresholdGwei = val;
|
||||
await saveState();
|
||||
}
|
||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||
});
|
||||
|
||||
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
||||
|
||||
@@ -10,14 +10,6 @@ const { formatEther, formatUnits } = require("ethers");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity. Every address comparison in
|
||||
// this file goes through this helper, so an address arriving in checksummed
|
||||
// or upper-case form can never be read as a different address.
|
||||
function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
function formatTxValue(val) {
|
||||
const parts = val.split(".");
|
||||
if (parts.length === 1) return val + ".0000";
|
||||
@@ -38,10 +30,10 @@ function parseTx(tx, addrLower) {
|
||||
let exactValue = formatEther(rawWei);
|
||||
let rawAmount = rawWei;
|
||||
let rawUnit = "wei";
|
||||
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||
if (toIsContract && method && method !== "transfer") {
|
||||
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
||||
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
|
||||
if (token) {
|
||||
symbol = token.symbol;
|
||||
}
|
||||
@@ -95,8 +87,7 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
const to = tt.to?.hash || "";
|
||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||
const rawVal = tt.total?.value || "0";
|
||||
const direction =
|
||||
normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||
const sym = tt.token?.symbol || "?";
|
||||
return {
|
||||
hash: tt.transaction_hash,
|
||||
@@ -113,9 +104,11 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
direction: direction,
|
||||
directionLabel: direction === "sent" ? "Sent" : "Received",
|
||||
isError: false,
|
||||
contractAddress: normalizeAddress(
|
||||
tt.token?.address_hash || tt.token?.address || "",
|
||||
),
|
||||
contractAddress: (
|
||||
tt.token?.address_hash ||
|
||||
tt.token?.address ||
|
||||
""
|
||||
).toLowerCase(),
|
||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
||||
};
|
||||
}
|
||||
@@ -201,7 +194,7 @@ function mergeTransactions(txs, tokenTransfers) {
|
||||
|
||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = normalizeAddress(address);
|
||||
const addrLower = address.toLowerCase();
|
||||
|
||||
const [txResp, ttResp] = await Promise.all([
|
||||
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
|
||||
@@ -250,38 +243,34 @@ function isSpoofedSymbol(tx) {
|
||||
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(symbol);
|
||||
if (legit === null) return true; // "ETH" as ERC-20 is always fake
|
||||
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
|
||||
return tx.contractAddress !== legit;
|
||||
}
|
||||
|
||||
// Pure filter function. Takes raw transactions and filter settings,
|
||||
// returns { transactions, newFraudContracts }.
|
||||
function filterTransactions(txs, filters = {}) {
|
||||
const fraudSet = new Set(
|
||||
(filters.fraudContracts || []).map(normalizeAddress),
|
||||
(filters.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||
);
|
||||
// The dust threshold defaults only when it is unset (nullish): a
|
||||
// threshold of 0 is a real value meaning "hide nothing", since no
|
||||
// transaction has a value below 0 gwei. It is therefore equivalent to
|
||||
// clearing the hide-dust checkbox, and the two controls cannot override
|
||||
// each other in either direction.
|
||||
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
|
||||
const newFraud = [];
|
||||
const filtered = [];
|
||||
|
||||
for (const tx of txs) {
|
||||
const contract = normalizeAddress(tx.contractAddress);
|
||||
|
||||
// Always filter spoofed known symbols and record the fraud contract
|
||||
if (isSpoofedSymbol(tx)) {
|
||||
if (contract && !fraudSet.has(contract)) {
|
||||
fraudSet.add(contract);
|
||||
newFraud.push(contract);
|
||||
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
|
||||
fraudSet.add(tx.contractAddress);
|
||||
newFraud.push(tx.contractAddress);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter fraud contracts if setting is on
|
||||
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
|
||||
if (
|
||||
filters.hideFraudContracts &&
|
||||
tx.contractAddress &&
|
||||
fraudSet.has(tx.contractAddress)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -302,7 +291,7 @@ function filterTransactions(txs, filters = {}) {
|
||||
filters.hideDustTransactions &&
|
||||
!tx.isContractCall &&
|
||||
tx.valueGwei !== null &&
|
||||
tx.valueGwei < dustThresholdGwei
|
||||
tx.valueGwei < (filters.dustThresholdGwei || 100000)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -329,42 +329,18 @@ describe("known-symbol spoof verification", () => {
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
// Regression guard (#179): EIP-55 mixed case is a checksum over the
|
||||
// address, not part of its identity, so the contract comparison must be
|
||||
// case-insensitive in both directions — a genuine token in any casing is
|
||||
// genuine, and a spoof cannot escape detection by changing its casing.
|
||||
test("a genuine contract in all-lowercase form is not a spoof", () => {
|
||||
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
|
||||
const tx = tokenTx({
|
||||
// Documents current behaviour, not desired behaviour: the spoof check
|
||||
// compares tx.contractAddress against a lowercased known address with
|
||||
// ===, so a caller passing a checksummed address for a genuine token has
|
||||
// it treated as a spoof. In the app this cannot happen because
|
||||
// parseTokenTransfer lowercases, but the exported function is not
|
||||
// defensive about it the way the blocklist check is.
|
||||
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
|
||||
const genuineButChecksummed = tokenTx({
|
||||
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
});
|
||||
const result = filterTransactions([tx], filters());
|
||||
expect(result.transactions).toEqual([tx]);
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuine contract in all-uppercase form is not a spoof", () => {
|
||||
const tx = tokenTx({
|
||||
contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
|
||||
});
|
||||
const result = filterTransactions([tx], filters());
|
||||
expect(result.transactions).toEqual([tx]);
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
|
||||
const tx = tokenTx({
|
||||
contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
|
||||
});
|
||||
const result = filterTransactions([tx], filters());
|
||||
const result = filterTransactions([genuineButChecksummed], filters());
|
||||
expect(result.transactions).toEqual([]);
|
||||
// The recorded fraud contract is normalised, so the persisted
|
||||
// blocklist matches later transfers whatever casing they arrive in.
|
||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||
});
|
||||
|
||||
// Documents current behaviour: README.md:810-814 says all four filters
|
||||
@@ -436,21 +412,6 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
||||
expect(tx.holders).toBeNull();
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
// Regression guard (#179): an unknown holder count on a real token — the
|
||||
// explorer rate-limited the call, or a self-hosted instance omits the
|
||||
// field — must not be read as zero holders. Reading it that way hides a
|
||||
// legitimate transfer from the user's history, the same over-filtering
|
||||
// harm as the zero-threshold bug. This pins the `tx.holders !== null`
|
||||
// guard, which no fixture previously reached.
|
||||
test("a token whose holder count is unknown is not filtered", () => {
|
||||
const tx = tokenTx({
|
||||
symbol: NOVEL_SPAM_SYMBOL,
|
||||
contractAddress: NOVEL_SPAM_CONTRACT,
|
||||
holders: null,
|
||||
});
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fraud contract blocklist", () => {
|
||||
@@ -614,50 +575,16 @@ describe("dust threshold filtering", () => {
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
|
||||
// not an absent one. It used to be swallowed by `|| 100000`, so the one
|
||||
// value a user would pick to see everything was the one that did not
|
||||
// work.
|
||||
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
|
||||
const dust = dustOf(50);
|
||||
const zero = dustOf(0);
|
||||
const opts = filters({ dustThresholdGwei: 0 });
|
||||
expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
|
||||
expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
|
||||
});
|
||||
|
||||
test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
|
||||
const tx = nativeDustTransfer();
|
||||
const thresholdZero = filterTransactions(
|
||||
[tx],
|
||||
// Documents current behaviour: the threshold is read as
|
||||
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
|
||||
// to 0 (the natural way to ask for no dust filtering while leaving the
|
||||
// toggle on) silently gets the 100,000 gwei default instead.
|
||||
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
|
||||
const result = filterTransactions(
|
||||
[dustOf(50)],
|
||||
filters({ dustThresholdGwei: 0 }),
|
||||
);
|
||||
const toggleOff = filterTransactions(
|
||||
[tx],
|
||||
filters({ hideDustTransactions: false }),
|
||||
);
|
||||
expect(thresholdZero.transactions).toEqual([tx]);
|
||||
expect(toggleOff.transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
test("0, unset and a set threshold are three distinct behaviours", () => {
|
||||
const tx = dustOf(50);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
|
||||
.transactions,
|
||||
).toEqual([tx]);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
|
||||
.transactions,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
|
||||
.transactions,
|
||||
).toEqual([tx]);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
|
||||
.transactions,
|
||||
).toEqual([]);
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption
|
||||
// that protects recovery phrases and private keys at rest.
|
||||
//
|
||||
// The properties that matter here are the ones whose failure is silent. A
|
||||
// vault that decrypts under the wrong password, that hands back plaintext from
|
||||
// a ciphertext an attacker edited, that reuses a nonce, or that leaves the
|
||||
// recovery phrase readable somewhere in the stored blob all look exactly like
|
||||
// a working vault from the UI. So each test below asserts a negative: the
|
||||
// thing that must not happen.
|
||||
//
|
||||
// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production
|
||||
// interactive parameters, which the module hardcodes. The parameters are not
|
||||
// weakened or overridden anywhere in this file — they are pinned by the "key
|
||||
// derivation cost" tests, since they are the vault's only defence against an
|
||||
// offline attack on a stolen blob. The suite is kept inside script/test's
|
||||
// 30-second budget by sharing one encrypted fixture across the tamper cases
|
||||
// instead of re-encrypting per test.
|
||||
|
||||
const sodium = require("libsodium-wrappers-sumo");
|
||||
const {
|
||||
encryptWithPassword,
|
||||
decryptWithPassword,
|
||||
} = require("../src/shared/vault");
|
||||
|
||||
// A publicly known development phrase. Never fund it.
|
||||
const SECRET = "test test test test test test test test test test test junk";
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
const WRONG_PASSWORD = "correct horse battery stapl";
|
||||
|
||||
const SALT_BYTES = 16;
|
||||
const NONCE_BYTES = 24;
|
||||
const POLY1305_TAG_BYTES = 16;
|
||||
|
||||
const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
||||
|
||||
function b64decode(s) {
|
||||
return sodium.from_base64(s);
|
||||
}
|
||||
|
||||
// A shallow copy with one field replaced, so the shared fixture is never
|
||||
// mutated by a tamper test.
|
||||
function withField(blob, field, value) {
|
||||
return { ...blob, [field]: value };
|
||||
}
|
||||
|
||||
// Flip the low bit of one byte of a base64-encoded field.
|
||||
function flipByte(b64, index) {
|
||||
const bytes = b64decode(b64);
|
||||
bytes[index] ^= 0x01;
|
||||
return sodium.to_base64(bytes);
|
||||
}
|
||||
|
||||
let vault;
|
||||
|
||||
beforeAll(async () => {
|
||||
await sodium.ready;
|
||||
vault = await encryptWithPassword(SECRET, PASSWORD);
|
||||
});
|
||||
|
||||
describe("stored blob shape", () => {
|
||||
test("is exactly the documented { salt, nonce, ciphertext }", () => {
|
||||
expect(Object.keys(vault).sort()).toEqual([
|
||||
"ciphertext",
|
||||
"nonce",
|
||||
"salt",
|
||||
]);
|
||||
});
|
||||
|
||||
test("every field is a base64 string", () => {
|
||||
for (const field of ["salt", "nonce", "ciphertext"]) {
|
||||
expect(typeof vault[field]).toBe("string");
|
||||
expect(vault[field]).toMatch(BASE64);
|
||||
}
|
||||
});
|
||||
|
||||
test("salt and nonce are full length", () => {
|
||||
expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES);
|
||||
expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES);
|
||||
});
|
||||
|
||||
test("ciphertext carries a Poly1305 authentication tag", () => {
|
||||
expect(b64decode(vault.ciphertext)).toHaveLength(
|
||||
SECRET.length + POLY1305_TAG_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
test("the blob survives JSON storage unchanged", async () => {
|
||||
const stored = JSON.parse(JSON.stringify(vault));
|
||||
|
||||
await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe(
|
||||
SECRET,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no plaintext leakage", () => {
|
||||
test("the secret does not appear in the serialized vault", () => {
|
||||
const serialized = JSON.stringify(vault);
|
||||
|
||||
expect(serialized).not.toContain(SECRET);
|
||||
for (const word of new Set(SECRET.split(" "))) {
|
||||
expect(serialized).not.toContain(word);
|
||||
}
|
||||
});
|
||||
|
||||
test("the ciphertext bytes do not contain the secret bytes", () => {
|
||||
const bytes = Buffer.from(b64decode(vault.ciphertext));
|
||||
|
||||
expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false);
|
||||
// Not even the first word, which would betray an unencrypted prefix.
|
||||
expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false);
|
||||
});
|
||||
|
||||
test("the password does not appear in the serialized vault", () => {
|
||||
expect(JSON.stringify(vault)).not.toContain(PASSWORD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round trip", () => {
|
||||
test("decrypts back to the original secret", async () => {
|
||||
await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe(
|
||||
SECRET,
|
||||
);
|
||||
});
|
||||
|
||||
test("survives a non-ASCII plaintext byte for byte", async () => {
|
||||
const unicode = "recovery phrase é中文\u{1f600}";
|
||||
|
||||
const blob = await encryptWithPassword(unicode, PASSWORD);
|
||||
|
||||
await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe(
|
||||
unicode,
|
||||
);
|
||||
});
|
||||
|
||||
test("an empty password still round-trips and is not a bypass", async () => {
|
||||
const blob = await encryptWithPassword(SECRET, "");
|
||||
|
||||
await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET);
|
||||
// An empty password must not act as a skeleton key on other vaults,
|
||||
// nor may a real password open an empty-password vault.
|
||||
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
|
||||
await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fresh salt and nonce", () => {
|
||||
test("two encryptions of the same plaintext differ in all three fields", async () => {
|
||||
const second = await encryptWithPassword(SECRET, PASSWORD);
|
||||
|
||||
expect(second.salt).not.toBe(vault.salt);
|
||||
expect(second.nonce).not.toBe(vault.nonce);
|
||||
expect(second.ciphertext).not.toBe(vault.ciphertext);
|
||||
await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe(
|
||||
SECRET,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key derivation cost", () => {
|
||||
// Argon2id's opslimit and memlimit are the whole of the vault's resistance
|
||||
// to an offline attack on a stolen blob, and lowering them breaks nothing
|
||||
// any other test here can see — the suite merely runs faster. So pin them
|
||||
// directly, both to libsodium's INTERACTIVE constants and to the absolute
|
||||
// values those constants must keep meaning.
|
||||
const INTERACTIVE_OPSLIMIT = 2;
|
||||
const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024;
|
||||
|
||||
test("the interactive constants still mean 2 passes over 64 MiB", () => {
|
||||
expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe(
|
||||
INTERACTIVE_OPSLIMIT,
|
||||
);
|
||||
expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe(
|
||||
INTERACTIVE_MEMLIMIT,
|
||||
);
|
||||
// The floor these must never quietly be swapped for: _MIN is one pass
|
||||
// over 8 KiB, an 8192x reduction in memory cost.
|
||||
expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan(
|
||||
INTERACTIVE_OPSLIMIT,
|
||||
);
|
||||
expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan(
|
||||
INTERACTIVE_MEMLIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
test("a key derived at the interactive parameters opens the vault", () => {
|
||||
// Independent of any spy, and of the module's own code path: derive
|
||||
// the key here from the vault's published salt at the interactive cost
|
||||
// and open its ciphertext directly. A vault whose key came from any
|
||||
// other opslimit, memlimit or Argon2id variant yields a different key
|
||||
// and cannot be opened this way.
|
||||
const key = sodium.crypto_pwhash(
|
||||
sodium.crypto_secretbox_KEYBYTES,
|
||||
PASSWORD,
|
||||
b64decode(vault.salt),
|
||||
INTERACTIVE_OPSLIMIT,
|
||||
INTERACTIVE_MEMLIMIT,
|
||||
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
const opened = sodium.crypto_secretbox_open_easy(
|
||||
b64decode(vault.ciphertext),
|
||||
b64decode(vault.nonce),
|
||||
key,
|
||||
);
|
||||
|
||||
expect(sodium.to_string(opened)).toBe(SECRET);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"encrypt",
|
||||
async () => {
|
||||
await encryptWithPassword(SECRET, PASSWORD);
|
||||
},
|
||||
],
|
||||
[
|
||||
"decrypt",
|
||||
async () => {
|
||||
await decryptWithPassword(vault, PASSWORD);
|
||||
},
|
||||
],
|
||||
])("%s derives exactly one key at the interactive cost", async (_, run) => {
|
||||
const spy = jest.spyOn(sodium, "crypto_pwhash");
|
||||
try {
|
||||
await run();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [keyBytes, , salt, opslimit, memlimit, alg] =
|
||||
spy.mock.calls[0];
|
||||
expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES);
|
||||
expect(salt).toHaveLength(SALT_BYTES);
|
||||
expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE);
|
||||
expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE);
|
||||
expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrong password", () => {
|
||||
test("is rejected, and rejects cleanly", async () => {
|
||||
// rejects.toThrow asserts a rejected promise, not a synchronous throw
|
||||
// and not an unhandled rejection: the caller can catch this.
|
||||
await expect(
|
||||
decryptWithPassword(vault, WRONG_PASSWORD),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("returns no plaintext, not even partially", async () => {
|
||||
const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch(
|
||||
(err) => err,
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(String(result)).not.toContain("test");
|
||||
});
|
||||
|
||||
test("the empty password is rejected on a password-protected vault", async () => {
|
||||
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tampering", () => {
|
||||
test("a flipped ciphertext bit is rejected by the auth tag", async () => {
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
flipByte(vault.ciphertext, 0),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a flipped bit in the authentication tag itself is rejected", async () => {
|
||||
const tagStart = b64decode(vault.ciphertext).length - 1;
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
flipByte(vault.ciphertext, tagStart),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a flipped nonce bit is rejected", async () => {
|
||||
const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0));
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a flipped salt bit is rejected", async () => {
|
||||
const tampered = withField(vault, "salt", flipByte(vault.salt, 0));
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a truncated ciphertext is rejected", async () => {
|
||||
const bytes = b64decode(vault.ciphertext);
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
sodium.to_base64(bytes.slice(0, bytes.length - 4)),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a ciphertext shorter than the auth tag is rejected", async () => {
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"ciphertext",
|
||||
sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a truncated nonce is rejected", async () => {
|
||||
const tampered = withField(
|
||||
vault,
|
||||
"nonce",
|
||||
sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)),
|
||||
);
|
||||
|
||||
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a ciphertext from another vault is rejected", async () => {
|
||||
const other = await encryptWithPassword("a different secret", PASSWORD);
|
||||
const spliced = withField(vault, "ciphertext", other.ciphertext);
|
||||
|
||||
await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a missing field is rejected rather than decrypted", async () => {
|
||||
for (const field of ["salt", "nonce", "ciphertext"]) {
|
||||
const broken = { ...vault };
|
||||
delete broken[field];
|
||||
|
||||
await expect(
|
||||
decryptWithPassword(broken, PASSWORD),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic
|
||||
// generation (first two describes), and HD key derivation against published
|
||||
// known-answer vectors (rest of the file).
|
||||
// Tests for the DEBUG build flag as it gates mnemonic generation.
|
||||
//
|
||||
// The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle
|
||||
// time. Under jest the global is absent, which is exactly the release-build
|
||||
@@ -94,317 +92,3 @@ describe("generateMnemonic in a debug build", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key derivation.
|
||||
//
|
||||
// Every address below is a published constant, not something this codebase
|
||||
// produced. Asserting against what the implementation happens to return today
|
||||
// would pass just as happily with the wrong coin type, the wrong path depth or
|
||||
// a non-empty seed passphrase, all of which silently send funds to addresses
|
||||
// no other wallet can recover.
|
||||
//
|
||||
// Vector sources:
|
||||
//
|
||||
// VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard
|
||||
// development recovery phrase and the first three accounts it yields at
|
||||
// m/44'/60'/0'/0/n with an empty seed passphrase, as published in the
|
||||
// Hardhat and Ganache documentation. Publicly known; never fund it.
|
||||
//
|
||||
// ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy
|
||||
// phrase (Trezor's official BIP-39 vector set, first entry) and its
|
||||
// m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second,
|
||||
// independently published phrase so the pin is not one vector deep.
|
||||
//
|
||||
// BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1
|
||||
// (seed 000102030405060708090a0b0c0d0e0f).
|
||||
//
|
||||
// The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the
|
||||
// published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD
|
||||
// path must meet at the same address from two different directions.
|
||||
|
||||
const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers");
|
||||
const wallet = require("../src/shared/wallet");
|
||||
const { BIP44_ETH_PATH } = require("../src/shared/constants");
|
||||
|
||||
const VECTOR_PHRASE =
|
||||
"test test test test test test test test test test test junk";
|
||||
|
||||
const VECTOR_ADDRESSES = [
|
||||
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
|
||||
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
||||
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
|
||||
];
|
||||
|
||||
const VECTOR_PRIVATE_KEYS = [
|
||||
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
|
||||
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
|
||||
];
|
||||
|
||||
const ZERO_ENTROPY_PHRASE =
|
||||
"abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about";
|
||||
const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94";
|
||||
|
||||
const BIP32_VECTOR_1_XPRV =
|
||||
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" +
|
||||
"ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi";
|
||||
|
||||
// The master (depth-0) extended private key for a phrase, which is what the
|
||||
// import-an-xprv flow is handed. Built with ethers rather than with the module
|
||||
// under test, so hdWalletFromXprv is not being checked against itself.
|
||||
function masterXprv(phrase, passphrase = "") {
|
||||
return HDNodeWallet.fromSeed(
|
||||
Mnemonic.fromPhrase(phrase, passphrase).computeSeed(),
|
||||
).extendedKey;
|
||||
}
|
||||
|
||||
describe("hdWalletFromMnemonic", () => {
|
||||
test("first address matches the published vector for m/44'/60'/0'/0/0", () => {
|
||||
expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe(
|
||||
VECTOR_ADDRESSES[0],
|
||||
);
|
||||
});
|
||||
|
||||
test("second published phrase derives its published address", () => {
|
||||
expect(
|
||||
wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress,
|
||||
).toBe(ZERO_ENTROPY_ADDRESS);
|
||||
});
|
||||
|
||||
test("returns the account-level xpub, which is watch-only", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
expect(xpub.startsWith("xpub")).toBe(true);
|
||||
// A neutered ethers node exposes no private key at all, so accept
|
||||
// either absent or null rather than pinning which.
|
||||
expect(
|
||||
HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null,
|
||||
).toBeNull();
|
||||
expect(wallet.isValidXprv(xpub)).toBe(false);
|
||||
});
|
||||
|
||||
test("the account path is the documented BIP-44 Ethereum path", () => {
|
||||
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
|
||||
});
|
||||
|
||||
test("rejects an invalid recovery phrase rather than deriving from it", () => {
|
||||
expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAddressFromXpub", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
test.each([0, 1, 2])(
|
||||
"child %i matches the published vector address",
|
||||
(index) => {
|
||||
expect(wallet.deriveAddressFromXpub(xpub, index)).toBe(
|
||||
VECTOR_ADDRESSES[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("agrees with hdWalletFromMnemonic at index 0", () => {
|
||||
expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(
|
||||
wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects garbage instead of returning an address", () => {
|
||||
expect(() =>
|
||||
wallet.deriveAddressFromXpub("xpub-nonsense", 0),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hdWalletFromMnemonic seed passphrase handling", () => {
|
||||
// The vectors above are only reproducible with an empty BIP-39 seed
|
||||
// passphrase. This pins that the empty string reaching
|
||||
// HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the
|
||||
// published address is unreachable, and a wallet derived that way could
|
||||
// not be restored anywhere else from the phrase alone.
|
||||
test("a non-empty seed passphrase would yield a different address", () => {
|
||||
const withPassphrase = HDNodeWallet.fromPhrase(
|
||||
VECTOR_PHRASE,
|
||||
"TREZOR",
|
||||
BIP44_ETH_PATH,
|
||||
).deriveChild(0).address;
|
||||
|
||||
expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hdWalletFromXprv", () => {
|
||||
// hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while
|
||||
// hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a
|
||||
// depth-0 master key the two are the same derivation; these tests pin that
|
||||
// equivalence to a published address rather than assuming it.
|
||||
test("master xprv for the vector phrase yields the vector address", () => {
|
||||
expect(
|
||||
wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress,
|
||||
).toBe(VECTOR_ADDRESSES[0]);
|
||||
});
|
||||
|
||||
test("agrees with hdWalletFromMnemonic on xpub and address", () => {
|
||||
const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
|
||||
|
||||
expect(fromXprv).toEqual(fromPhrase);
|
||||
});
|
||||
|
||||
test("derived xpub generates the same child addresses", () => {
|
||||
const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
|
||||
|
||||
expect(
|
||||
[0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
|
||||
).toEqual(VECTOR_ADDRESSES);
|
||||
});
|
||||
|
||||
test("accepts the BIP-32 test vector 1 master key", () => {
|
||||
const { xpub, firstAddress } =
|
||||
wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
|
||||
|
||||
expect(xpub.startsWith("xpub")).toBe(true);
|
||||
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
||||
});
|
||||
|
||||
test("rejects a watch-only xpub", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
expect(() => wallet.hdWalletFromXprv(xpub)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects garbage", () => {
|
||||
expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidXprv", () => {
|
||||
test.each([
|
||||
["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true],
|
||||
["the empty string", "", false],
|
||||
["garbage", "not-a-key", false],
|
||||
["a bare private key", VECTOR_PRIVATE_KEYS[0], false],
|
||||
["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false],
|
||||
["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false],
|
||||
])("%s -> %s", (_name, key, expected) => {
|
||||
expect(wallet.isValidXprv(key)).toBe(expected);
|
||||
});
|
||||
|
||||
test("a watch-only xpub is not an xprv", () => {
|
||||
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
|
||||
|
||||
expect(wallet.isValidXprv(xpub)).toBe(false);
|
||||
});
|
||||
|
||||
// Skipped: this asserts the correct behaviour, which the code does not
|
||||
// currently have. isValidXprv gates the paste-your-extended-private-key
|
||||
// import in src/popup/views/addWallet.js:215, and it accepts a key with a
|
||||
// one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58
|
||||
// checksum verification whenever the decoded payload is the usual 82
|
||||
// bytes, which is the whole point of that checksum. Measured on this
|
||||
// vector: changing any one of the last 14 characters passes validation,
|
||||
// and for 9 of those 14 positions the import silently yields a *different*
|
||||
// wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of
|
||||
// 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown.
|
||||
// Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; out of scope
|
||||
// here, which is tests only. Unskip when it is fixed.
|
||||
test.skip("rejects an extended key with a one-character typo", () => {
|
||||
const index = BIP32_VECTOR_1_XPRV.length - 8;
|
||||
const typo =
|
||||
BIP32_VECTOR_1_XPRV.slice(0, index) +
|
||||
(BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") +
|
||||
BIP32_VECTOR_1_XPRV.slice(index + 1);
|
||||
|
||||
expect(wallet.isValidXprv(typo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidMnemonic", () => {
|
||||
test.each([
|
||||
["the vector phrase", VECTOR_PHRASE, true],
|
||||
["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true],
|
||||
[
|
||||
"a 12-word phrase with a bad checksum",
|
||||
"abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon abandon",
|
||||
false,
|
||||
],
|
||||
["an 11-word phrase", "abandon ".repeat(10) + "about", false],
|
||||
["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false],
|
||||
["the empty string", "", false],
|
||||
["garbage", "correct horse battery staple", false],
|
||||
])("%s -> %s", (_name, phrase, expected) => {
|
||||
expect(wallet.isValidMnemonic(phrase)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addressFromPrivateKey", () => {
|
||||
test.each([0, 1, 2])(
|
||||
"published key %i yields its published address",
|
||||
(index) => {
|
||||
expect(
|
||||
wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]),
|
||||
).toBe(VECTOR_ADDRESSES[index]);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a key of the wrong length", () => {
|
||||
expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow();
|
||||
});
|
||||
|
||||
test("rejects the empty string", () => {
|
||||
expect(() => wallet.addressFromPrivateKey("")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSignerForAddress", () => {
|
||||
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "hd" },
|
||||
index,
|
||||
VECTOR_PHRASE,
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
|
||||
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
|
||||
});
|
||||
|
||||
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
index,
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
|
||||
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
|
||||
});
|
||||
|
||||
test("single private key ignores the address index", () => {
|
||||
for (const index of [0, 1, 2]) {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "privkey" },
|
||||
index,
|
||||
VECTOR_PRIVATE_KEYS[1],
|
||||
);
|
||||
|
||||
expect(signer.address).toBe(VECTOR_ADDRESSES[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test("the returned signer signs recoverably as the expected address", async () => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "hd" },
|
||||
1,
|
||||
VECTOR_PHRASE,
|
||||
);
|
||||
const message = "AutistMask derivation test";
|
||||
|
||||
const signature = await signer.signMessage(message);
|
||||
|
||||
expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user