A crypto holder reads an email that looks like it comes from Ledger. It says that a “quantum patch” is ready, and it links to a migration tool. The tool asks for the 24 words of the wallet backup.
That email is a scam. Ledger and Trezor do not send a “quantum patch” by email. The Bunker background notes, dated October 25, 2026, record fake emails of this kind.
A worried person can also paste those 24 words into a real tool by accident. Bunker is built for exactly that moment. Before it reads a single address, it looks for secrets. If it finds one, it clears the box and explains why.
This article takes that guard apart, with the code, the hard cases and real runs of each case.
Two words first: seed phrase and private key
A seed phrase is a list of 12 or 24 words that makes all the private keys of a wallet. A private key is the secret that moves your coins. Anybody who has either one can empty the wallet.
Bunker needs neither. It needs only public addresses.
Why the guard blocks and clears
Decision record ADR-010 compares three options:
| Option | For | Against |
|---|---|---|
| A. No guard | No false alarms | The tool does nothing to stop a dangerous habit. |
| B. Warn, but keep the text | The user can fix a false alarm. | The secret stays on screen, in screenshots and in screen recordings. |
| C. Block, clear the input, and explain | The secret leaves the screen at once. The user learns the rule. | A false alarm clears real addresses. |
Bunker uses option C. The reason is a comparison of two costs:
- The cost of a false alarm is small. The user pastes the addresses again.
- The cost of a missed secret is the total loss of the funds.
The message also teaches a rule for every other site: real tools never ask for a seed phrase.
The guard runs before anything else
Every scan starts in src/parse.js. Its function parseInput() calls the guard first, on the whole text. If the guard finds a secret, the function returns an empty list, and no address is classified.
The page then acts on that result. This is the scan function of the page, from src/ui/app.js:
function uiScan(text, example) {
const res = parseInput(text);
const textarea = /** @type {HTMLTextAreaElement} */ (uiById('addrs'));
if (res.secret) {
textarea.value = '';
uiShowSecretAlert(res.secret);
uiUpdateCount();
return;
}
Read it in order:
parseInput(text)runs the guard first.- If
res.secrethas a value, the guard found something. textarea.value = ''empties the input box. The secret leaves the screen.uiShowSecretAlert()names the kind of secret, not the secret itself.returnstops the scan. No result and no plan appear.
The page does not wait for the Scan button. Lines 169 to 174 of the same file check the text at once when you paste it or drop it into the box. A seed phrase does not stay on screen while you look for the button.
For a seed phrase, the warning has two parts. The English page shows this text:
From src/ui/text.js:
This looks like {what}. The page cleared it.
This page did not send it anywhere. Real tools need only your public address. Anybody who asks for a seed phrase or private key wants to empty your wallet.
The page puts the kind of secret, such as “a seed phrase”, in place of {what}.
What the guard looks for
The guard knows 19 kinds of secrets. They fall into four families:
| Family | Examples |
|---|---|
| Seed phrases | 12 or 24 words, in any of the BIP-39 word list languages |
| Raw keys | 64 or 128 hex characters, WIF keys, Solana keys, Stacks keys, DER-encoded keys |
| Keys with a known prefix | Extended private keys (xprv), Nostr (nsec1), Stellar, XRP seeds, Tezos, Cardano, Mina, Sui, BIP-38 encrypted keys |
| Key files | PEM files, PKCS#8 and SEC1 key bodies, encrypted wallet files, Solana key files, NEAR credential files |
All of this code is in one file of 94 lines, src/guard.js. The main function is findSecret():
export function findSecret(text) {
const clean = text.replace(INVISIBLE, '');
// ...
const raw = clean.split(SPLIT).filter(Boolean);
// A key file as a list of 64 bytes, for example the Solana id.json file.
const bytes = raw.filter(t => /^\d{1,3}$/.test(t) && Number(t) < 256);
if (bytes.length >= 64 && bytes.length >= raw.length * 0.6) return 'a private key (JSON key file)';
const tokens = raw.map(t => t.replace(GLUED_NUMBER, '')).filter(t => t && !NUMBERING.test(t));
if (longestWordRun(tokens) >= 12) return 'a seed phrase';
for (const t of tokens) {
const hit = secretInToken(t);
if (hit) return hit;
}
return null;
}
The function works in five steps:
- Remove invisible characters. Zero-width spaces and direction marks can hide between the words of a phrase.
- Look for whole files. The two lines in place of
// ...catch the header of a PEM key file and the fields of an encrypted wallet file. - Split the text into tokens. A token is one piece of text between separators, such as spaces, quotes, brackets or
=. - Look for a list of 64 bytes. A Solana key file is a JSON list of 64 numbers below 256.
- Look for a seed phrase, then test each token. A run of 12 or more seed words is a phrase.
secretInToken()then tests each token against the key formats.
The function returns a short description, such as “a seed phrase”, or null when the text looks safe. It never returns the secret itself.
Seed phrases in every form
People write seed phrases in many forms. Many number the words on the backup card. Others use capital letters, or a word list in their own language.
Before version 1.2.1, three of these forms passed the guard: a numbered phrase, a phrase in capital letters, and a Spanish phrase. The security review found this gap, and the project records it as problem P-33.
From docs/06-problems-and-fixes.md, P-33:
The guard counted only lower-case tokens of 3 to 8 ASCII letters. Numbering tokens such as “1.” counted against the 60 percent rule. Accented letters and other scripts did not count as words.
One fix was to embed the ten official BIP-39 word lists and match each word. I rejected it. The lists hold 20,480 words, about 150 KB, and a phrase with one typo would still pass.
The guard matches the shape of a seed word instead. These are the rules, from the same file:
// A seed word in any BIP-39 list: 3 to 8 letters (Latin lists, with accents in the Spanish, French,
// Italian, Portuguese and Czech lists), one Chinese character, or 2 to 6 kana or Hangul syllables.
const SEED_WORD = /^(\p{L}{3,8}|[一-鿿]|[-ヿ가-]{2,6})$/u;
// List numbering that people type before each seed word: "1.", "2)", "12:", "#3".
const NUMBERING = /^#?\d{1,2}[.):-]?$/;
// ...
function longestWordRun(tokens) {
let best = 0;
let run = 0;
for (const t of tokens) {
run = SEED_WORD.test(t.toLowerCase()) ? run + 1 : 0;
if (run > best) best = run;
}
return best;
}
Here is how the parts work together:
SEED_WORDmatches 3 to 8 letters in any script.\p{L}means “any letter”, so “ábaco” counts.NUMBERINGmatches the numbers that people write before each word, such as “1.” or “12:”.findSecret()removes these tokens before it counts.toLowerCase()makes “ABANDON” and “abandon” the same word.longestWordRun()counts seed words in a row. Any other token sets the count back to zero.
I ran the command-line tool on four forms of the 12-word test phrase from test/guard.test.js:
| Form of the input | Result | Exit code |
|---|---|---|
1. abandon 2. ability 3. able ... | SECRET_DETECTED, a seed phrase | 2 |
ABANDON ABILITY ABLE ... | SECRET_DETECTED, a seed phrase | 2 |
1.abandon 2.ability 3.able ... | SECRET_DETECTED, a seed phrase | 2 |
ábaco abdomen abeja ... (Spanish list) | SECRET_DETECTED, a seed phrase | 2 |
The third form has the number glued to the word. A separate rule, GLUED_NUMBER, removes “1.” from “1.abandon” before the count. The test suite also covers French, Chinese, Japanese and Korean phrases.
A list of addresses is not a seed phrase
The fix for P-33 created a new problem. The first version of it counted all the words in the input. Labels are words too.
The security review pasted eight addresses, each with the label “savings for the house”. That is more than 12 words, so the guard cleared a list of public addresses (P-38).
A false alarm is safe, but it has a cost. A user who loses a typed list once does not come back.
The fix is the run count that you saw above. A real phrase is always one run of 12 to 24 words. Labels sit between addresses, and an address breaks the run. I ran the input from the test suite. It has eight lines of the same address, with the labels “savings for the house 0” to “savings for the house 7”.
$ node cli/bunker.js --json labeled.txt
{
"ok": true,
// ...
"summary": {
"total": 1,
"lockVisible": 0,
"checkNeeded": 1,
"lockHidden": 0,
"notRated": 0
},
"results": [
{
"address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
"label": "savings for the house 0",
These are the first lines of the real output. The guard let the list through. The longest run is four words, “savings for the house”. The number after the label is a numbering token, and the next address resets the count.
The total is 1 because the test repeats one address eight times. The parser keeps the first line and skips the duplicates.
When a real address looks like a key
Some valid addresses have the same shape as a private key. The guard must block the key and let the address pass.
P-04, Internet Computer accounts. An Internet Computer (ICP) account ID is 64 hex characters. A raw private key is also 64 hex characters. The early guard cleared valid ICP accounts.
P-05, Sui and Aptos addresses. During the rewrite, a draft rule blocked 0x followed by 64 hex characters. That is the normal format for Sui, Aptos, IOTA and Starknet.
These lines of secretInToken() hold both fixes. The last lines are for WIF keys, which the next section explains:
if (/^[0-9a-fA-F]{64}$/.test(t) && !icpAccountValid(t)) return 'a raw private key, or an address without its prefix';
// ...
// WIF (Bitcoin, Litecoin, Dogecoin, Dash, Zcash and others): Base58Check with a 33 or 34 byte payload.
// No public address has that shape, so the version byte does not matter.
if (/^[1-9A-HJ-NP-Za-km-z]{51,52}$/.test(t)) {
const p = base58CheckDecode(t);
if (p && (p.length === 33 || p.length === 34)) return 'a private key (WIF)';
}
The first line does three checks in a row:
- The regular expression matches exactly 64 hex characters, with nothing in front. So
0xplus 64 hex never matches, and Sui, Aptos, IOTA and Starknet addresses pass. icpAccountValid(t)tests the ICP checksum. An ICP account ID starts with a CRC-32 checksum of the other 28 bytes. A checksum is extra data that lets software find a typo.- If the checksum is wrong, the token is treated as a key.
A random private key passes as an ICP account only if its first 4 bytes match the CRC-32 of the rest. The chance is 1 in 2^32, about 1 in 4 billion.
I ran the four cases. The test suite builds the 64 hex characters by repeating “ab” 32 times, so no real key appears here:
| Input | Result |
|---|---|
abab...ab (64 hex, no prefix) | Blocked: a raw private key, or an address without its prefix. Exit code 2. |
0xabab...ab | Passed: Sui, with the choices Sui, Aptos and IOTA |
near:abab...ab | Passed: NEAR implicit account, Lock visible |
a6b9670e...61bd01b0 (an ICP account ID with a valid checksum, from the tests) | Passed: Internet Computer, ICP account identifier |
For bare 64 hex, the page adds a third part to the warning. It tells the user to put 0x in front for Sui, Aptos or IOTA, or near: in front for a NEAR implicit account. The near: prefix is a Bunker convention, so the tool does not read the hex as a key.
Keys by shape, not by coin
WIF (Wallet Import Format) is a common text form of a private key. Bitcoin, Litecoin, Dogecoin, Dash and many other coins use it. Each coin puts its own version byte at the start, so the first character changes from coin to coin.
The early guard checked a list of first characters. That list missed X and 7, the first characters of Dash keys. The review found that a Dash key passed, and the project records it as P-34.
A longer list would fail again with the next coin. So the code above checks the shape:
- The token has 51 or 52 Base58 characters. Base58 is a text encoding without the characters 0, O, I and l.
base58CheckDecode()decodes it and verifies its 4-byte checksum.- If the payload has 33 or 34 bytes, the token is a WIF key.
No public address has that shape, so the version byte does not matter. The rule covers Bitcoin, Dash, Komodo and every other coin with a WIF format. The tests use Dash and Komodo keys that the project builds with its own encoder, so no real key is in the repository.
The same review went through the key formats of every supported network. It found eight more formats that the guard missed, recorded as P-42. The fix added these rules:
- XRP seeds, BIP-38 encrypted keys and Mina keys.
- Solana key files and NEAR credential files. A NEAR public key has only 32 bytes, so it still passes.
- PKCS#8 and SEC1 key bodies without their header line.
- Keys inside JSON quotes, inside brackets, or after
=. - Seed phrases with glued numbering or invisible characters.
The split in step 3 keeps a key inside a config file as one token. Here is a real run with the test input:
$ node -e "console.log('{\"key\":\"' + 'ab'.repeat(32) + '\"}')" | node cli/bunker.js --json
{
"ok": false,
"error": {
"code": "SECRET_DETECTED",
"message": "The input looks like a raw private key, or an address without its prefix. Nothing was processed, stored or sent. Use public addresses only. Never share a seed phrase or private key."
}
}
What the guard never does
The guard has three hard limits. Each one has a place in the code:
| The guard never… | How the code makes sure |
|---|---|
| sends the secret | The page has no network code, and its security policy blocks all connections. Article 7 shows this in detail. |
| stores the secret | The page uses no cookies and no browser storage. A test fails the build if this changes. |
| repeats the secret | The guard returns only a description, and the error message uses only that description. |
The third limit matters most for the command-line tool and the MCP server, because their output can end up in logs. These lines build the error, from src/agent/report.js:
const res = parseInput(text);
// The secret itself is never repeated in the output.
if (res.secret) {
const message = es
? 'La entrada parece ' + tr(res.secret) + '. No se procesó, guardó ni envió nada. Usa solo direcciones públicas. Nunca compartas una frase semilla ni una clave privada.'
: 'The input looks like ' + res.secret + '. Nothing was processed, stored or sent. Use public addresses only. Never share a seed phrase or private key.';
return { ok: false, code: 'SECRET_DETECTED', message };
}
The message uses res.secret, the description from the guard. The input text never goes into the message.
The guard checks the whole input, not each line alone. I put a valid address on line 1 and the test phrase on line 2. The tool processed nothing and returned exit code 2.
What the guard cannot do
The threat model of the project states the limits of the guard:
- A secret in a format that the guard does not know can pass. The page still sends nothing, so the secret stays on your device.
- A seed phrase that you typed into an AI chat is already in that chat. Bunker refuses it, but it cannot erase it from the chat history. Article 10 covers this case.
- Malware on your computer can read the text before Bunker does. No tool can protect a compromised device.
If you ever typed a seed phrase into a website or a chat, treat it as exposed. Move the funds to a new wallet.
Run the guard tests yourself
The guard has its own test file, test/guard.test.js. It tests both directions. Every kind of secret must be blocked, and 8 inputs that look like secrets must pass.
- Install Node.js 22.23.4, 24.22.1 or 26.12.1, or a newer release of the same line.
- Type
git clone https://github.com/nosistech/nosis-bunker.gitand press Enter. - Type
cd nosis-bunkerand press Enter. - Type
node --test test/guard.test.jsand press Enter.
There is nothing to install, because the project has no dependencies. These are the result lines of my run on October 27, 2026:
ok 1 - blocks seed phrases and private keys
ok 2 - blocks seed phrases in other forms and languages, and WIF keys of other coins
ok 3 - does not block real addresses or labels
ok 4 - parseInput clears everything when a secret is present
ok 5 - parseInput limits and duplicates
# tests 5
# pass 5
# fail 0
Read the code and the records
- The tool: bunker.nosistech.com
- The guard: src/guard.js
- The decision: ADR-010
- The problem log: docs/06-problems-and-fixes.md
In the next article, I show why the page cannot send your data even if a bug tries to. The browser itself enforces the rule, through a strict Content Security Policy.
Have a question or feedback about Bunker? Contact NosisTech.
Keep reading: Bunker by NosisTech
Previous article: One Wrong Character: How Bunker Catches Typos in Crypto Addresses
Next article: A Web Page That Cannot Send Your Data: The Browser Security of Bunker
All articles: NosisTech Writing
This post is general information, not financial, legal or security advice. Bunker is an educational tool with no warranty. Examine each address on a block explorer, and send a small test amount before you move funds.