koinu.dog: dogecoin wallet in c, built from the crypto up
no account, no server, no telemetry, no fee. mit licensed and free, and meant to be read.
one rule shapes the rest: the process holding keys is never the process talking to peers. the signing library references no function that can open a connection, and the build fails if one appears.
every primitive it uses is in the tree, small enough to follow, and checked against published vectors, so the wallet doubles as a worked example of how a wallet works. sha2, ripemd160, hmac, pbkdf2, scrypt, argon2id, chacha20-poly1305, siphash, base58 and the bip158 golomb set live in crypto/, either vendored from a named upstream at a named commit or written here, and frozen.
two ways in
what it does
- bip39 mnemonics of 12 or 24 words, bip32 derivation, bip44 accounts, seed sealed under argon2id and chacha20-poly1305
- p2pkh addresses, wif import, base58check on mainnet, testnet and regtest
- legacy transaction building and signing, byte for byte against libdogecoin's signer on 322 spends the network already accepted
- p2sh multisig co-signing, and the bip174 psbt subset dogecoin needs
- slip-39 and codex32 seed backups, threshold and grouped
- header sync checking scrypt work, auxpow, digishield, median time past and compiled-in anchors
- bip157/158 compact filters verified against the peer's filter-header chain, or full-block spv where no peer serves filters
- tor through socks5 with a fresh credential pair per connection
- kwd, a daemon answering outpoint queries over a unix socket, and kwui, a terminal view that never touches the network
use / start
build, then make a wallet
clone, build, and make a wallet. what each command guarantees, and what it refuses.
git clone --recursive https://github.com/xanimo/koinu
cd koinu
make check # builds libkw.a and kw, runs the tests
make asan # the same under address and ub sanitizers
--recursive is not optional. libsecp256k1 is a submodule, so a plain clone leaves depends/secp256k1 empty and the build stops on a missing secp256k1.h. in a clone you already have, run git submodule update --init. check a release tag against the key on the verify page before building it.
the suite is 40 binaries and 8 shell gates, every primitive against published vectors where they exist, and the fuzz corpus replays its seeds under asan and ubsan before mutating. ci runs it on linux x86_64 under gcc and clang, on linux i386 and on macos arm64.
make split # libkoinu-sign.a and libkoinu-chain.a
make install PREFIX=/usr/local # archives, headers, pkg-config, kw kwd kwui
a wallet
kw new --keystore w.ks # generate, seal, show the mnemonic
kw restore --keystore w.ks --mnemonic - # seal an existing mnemonic
kw address --keystore w.ks # derive m/44'/3'/0'/0/index
kw export xpub --keystore w.ks # the account public key
the mnemonic is printed once and never stored, so the words are the only backup. kw new asks for four of them back before it writes anything. how the words become keys is the keys chapter; how the keystore seals them is keystore.
passphrases, mnemonics and private keys are read from a file (@path), stdin (-) or a no-echo prompt. a bare value on the command line is refused, because argv is readable by any process on the machine. --testnet and --regtest switch networks; --tor routes every connection through socks5 at 127.0.0.1:9050 and resolves names through it.
use / backup
backup
checking a backup without restoring it, and splitting a seed so that fewer than a threshold is nothing.
a backup you have never checked is a guess, and checking one used to mean restoring it somewhere, which is how checking a backup becomes the thing that loses it. kw backup verify derives the seed the typed words give, opens the keystore, and compares the two in constant time, printing neither. a mismatch exits 3.
kw backup verify --keystore w.ks --mnemonic @phrase.txt
kw backup verify --mnemonic @phrase.txt # no wallet: fingerprint and first address
shares
kw backup split --keystore w.ks --groups 2of3
kw backup split --keystore w.ks --groups 1of1,2of3 --group-threshold 1
kw backup combine --keystore new.ks --shares @shares.txt
split prints the seed as slip-39 shares to stdout. 2of3 is three shares needing two. groups nest a second threshold over the first: 1of1,2of3 with group threshold 1 is "your own share, or two of the family's", and with 2 it is "your share and two of the family's". combine seals a keystore from exactly a threshold, refusing a spare share, and never prints the seed; to check a set, point it at a scratch path and compare the fingerprint.
a 64-byte seed makes 59-word shares, which other slip-39 software combines and trezor firmware will not accept. --backup-passphrase is a third secret, and a wrong one yields a different wallet rather than an error.
kw backup split --keystore w.ks --format codex32 --groups 3of5 --id kw00
codex32 writes the same split as bip-93 strings, checkable with a pen and a printed table. one flat threshold of 2 to 9, up to 22 shares, no passphrase, no grouping. why the two formats trade off as they do is the shares chapter.
use / spend
spend
scan, sign, send. the three ways a broadcast ends, and why an air-gapped spend is two commands.
kw scan --keystore w.ks --node NODE --headers h --filters f
kw sign --keystore w.ks --to DEST:100
kw send --tx @tx.hex --node NODE # decodes it and asks first
scan watches the first --gap receive and change addresses, extends the range until enough unused ones trail the highest used, and writes the utxo set beside the keystore. it forgets the seed and master key before it connects. sign selects inputs and builds the transaction; send broadcasts it. sign --raw puts the transaction alone on stdout and the decoded outputs and fee on stderr, so it pipes without losing the readback:
kw sign --keystore w.ks --to DEST:100 --raw | kw send --tx - --node NODE --yes
what send's exit code means
a node acknowledges nothing it accepts, so send asks for the mempool afterwards and looks for the transaction.
- 0
- broadcast: the node listed it
- 4
- unknown: no reject, not listed (an orphan, or a repeat of something already rejected)
- 1
- rejected, with the node's reason
the fee defaults to the peer's advertised relay floor captured during scan; --feerate sets a rate and --fee an exact amount. change below the dust limit goes to the fee. change goes to the first address the utxo set and the journal agree is unused, and signing records it, so consecutive spends do not share one.
air-gapped
kw psbt build --xpub XPUB --utxos u --to DEST:100 # online, cannot sign
kw psbt sign --psbt @psbt.hex --keystore w.ks # offline, opens no socket
kw psbt extract --psbt @signed.hex | kw send --tx - --node NODE --yes
the signer takes each input's amount and script from a previous transaction carried in the psbt, after requiring those bytes to hash to the outpoint being spent. why that matters for a legacy chain is in transactions.
sweep
kw sweep --watch ADDR --utxos u --node NODE --spv # finds the coins, reads no key
kw sweep --wif @k --to DEST --utxos u # signs, opens no socket
kw send --tx @tx.hex --node NODE
two runs so the one holding the key never opens a socket. passing both halves at once is refused, and the signing step drops any output not locked to the key it holds.
multisig
kw cosign --tx HEX --redeem HEX --wif @k # one signature for the counterparty
kw cosign --tx HEX --redeem HEX --wif @k --finish # assemble, checking every signature
kw psbt create | tx | sign | combine | sigs | finalize | extract
cosign --finish verifies each collected signature against the sighash and refuses the set if one fails. psbt finalize writes the scriptSig it is given, because scripts like a payment channel's OP_IF branch do not classify for a standard finalizer; it cannot verify that, so the caller does. combine refuses a conflict rather than picking a side, and a psbt carrying segwit or unknown fields is refused rather than parsed with those fields dropped.
use / tools
kwd and kwui
a daemon that holds the chain open, and a terminal view that never connects.
kwd, the chain held open
kw loads the header cache on every invocation, about 1.3 s and growing with the height. kwd pays that once, holds the chain and its peers open, and answers outpoint queries over a unix socket: confirmed or not, to what depth, spent or not. it holds no keys and opens no keystore, so a compromised kwd can lie about a confirmation and cannot sign.
kwd --node NODE --headers h --filters f --socket /run/kwd.sock
kw outpoint --daemon /run/kwd.sock --watch ADDR --outpoint TXID:VOUT --since H
measured against mainnet at height 6380305 with a filter cache from a node serving bip158. the chain is past 6.4 million now, and the last two numbers grow with it, so read these as the shape of the cost rather than today's figures:
8 to 25 ms repeated query
287 ms 80000 block range
2884 ms first request after startup (pays the delta sync)
1572 ms 46 blocks with no filters, fetching every block
the first number sizes a budget and the last sizes a range. where the peer serves no filters, which is every stock 1.14 node, kwd says so and answers from blocks, so a request costs every block from its since height to the tip. a request with no since height is refused rather than read as zero. a coinbase output under maturity reports "unspent but immature" and exits 5.
the socket is created 0600 with no group and no mode option. whatever asks has to run as kwd's user; another account gets permission denied on a path that otherwise looks right.
kwui, a view with no socket
a terminal view of a wallet: the balance, and every derived address with what it holds. it reads the keystore and the utxo set kw scan wrote and never connects to a peer, so the process holding keys is not the process talking to them.
kwui --keystore w.ks
- j / k
- move
- u
- hide addresses nothing has paid
- s
- compose a spend
- q
- quit
a spend asks for a destination and amount, shows the fee and change before anything is signed, asks for the passphrase again as the last confirmation, and writes the signed transaction out for kw send.
use / library
library
two archives, one direction of dependency, and no abi promise.
- libkoinu-sign.a
- keys, derivations, share formats, keystore, transaction builder, psbt, utxo tracking. references no function that can open a connection.
- libkoinu-chain.a
- peers, headers, auxpow, difficulty, filters. depends on the signing half, never the reverse.
#include <koinu/koinu-sign.h>
cc prog.c $(pkg-config --cflags --libs koinu-sign) -lsecp256k1
secp256k1 is yours to provide. the tree builds its own from a pinned submodule and does not install it, since a second libsecp256k1 on a system is how a program links two of them with no warning.
there is no shared library and no abi promise. the header types are transparent structs that still change (kw_utxo grew two fields when amounts were bound to their transactions), so pin a commit and build from source. neither archive exports a name outside kw_ and KW_; the vendored argon2 and poly1305-donna arrive with initialize, finalize and 53 others, which crypto/vendor/kw_symbols.h renames at compile time rather than patching the vendored sources. the library prints nothing unless the caller installs a log sink.
how the split is enforced is the boundary chapter.
use / verify
verify a release
the pgp key release tags are signed with.
release tags are signed with this pgp key:
00D7 BCFF 5778 2D5D 91D6 281A 0DC6 4171 D69D 92F4
gpg --recv-keys 00D7BCFF57782D5D91D6281A0DC64171D69D92F4
git tag -v v0.4.0
if the fingerprint on your screen does not match the one above, stop. the key is also in the tree at docs/bluezr.asc, which only helps if you already trust the tree. report vulnerabilities through SECURITY.md.
learn / contents
how a wallet works, read through koinu
eleven chapters, from 256 random bits to choosing between three peers' chains.
a wallet is three jobs that are easy to blur together: keep a secret, sign with it, and find out what the chain says about the addresses it controls. the first two never need a network and the third never needs a key. most of what is careful in koinu follows from refusing to let those jobs share a process.
each chapter explains one step, quotes the code that does it, and links the file it lives in. the code is c, but the excerpts are short and the ideas carry to any language. read in order or jump in.
part one the secret
- keysentropy to words to seed to an address beginning with D
- keystoresealing a seed with a passphrase, 52 bytes of header at a time
- memoryzeroing that the compiler cannot remove, mlock that counts, no core files
part two signing and backup
- transactionswhat a legacy signature commits to, and the amount it does not
- sharessplitting a seed so that fewer than a threshold is nothing at all
part three the chain
- headersscrypt, nBits, work, and dogecoin's off-by-one retarget
- auxpowchecking work borrowed from another chain
- filtersfinding your coins without telling a peer which they are
- peerschoosing a chain when you cannot trust who serves it
part four the shape of it
chapters 1, 2, 4, 5, 6, 7 and 8 have panels that run koinu's own c in the page: the same files kw links, compiled to webassembly by build.sh with a small shim, kw_web.c, that turns strings into calls and results into json. two things differ from the native build, and the shim says so in its first comment: randomness comes from the browser's crypto.getRandomValues, and nothing is mlock'd, because wasm memory has no pages to pin. it is a teaching build, not a wallet.
the full threat model is docs/threat-model.md and every vendored primitive's origin is in docs/PROVENANCE.md. both are worth reading whole.
learn / 1
keys
entropy to words to seed to an address beginning with D
everything a wallet controls comes from one random number. the steps from there to an address are each a published standard, and each is a small file in crypto/.
entropy
128 or 256 bits come from getrandom(2), falling back to /dev/urandom only on a kernel too old to have it. a short read fails rather than returning less than was asked for, because a key with fewer random bits than it claims looks exactly like a good one.
source: crypto/rng.c
words (bip39)
append a checksum (the first bits of the entropy's sha-256, one per 32 bits of entropy) and cut the result into 11-bit pieces. each piece indexes a 2048-word list, so 128 bits make 12 words and 256 make 24. the words are an encoding of the entropy, with a checksum so a mistyped word is caught.
the seed is not the entropy. it is pbkdf2-hmac-sha512 with the words as the password, the string "mnemonic" plus an optional passphrase as the salt, and 2048 rounds:
memcpy(salt, "mnemonic", 8);
memcpy(salt + 8, passphrase, plen);
int ok = kw_pbkdf2_hmac_sha512((const uint8_t *)norm, mlen,
salt, saltlen, 2048, seed, KW_BIP39_SEED_LEN);
so a passphrase does not unlock the wallet, it selects a different one. every passphrase yields a valid wallet, which is why a wrong one restores an empty wallet rather than an error. the wordlist is checked in the tests to reproduce the canonical file byte for byte, by sha-256.
source: crypto/bip39.c · crypto/pbkdf2.c
a tree of keys (bip32)
the master key is hmac-sha512 keyed with the string "Bitcoin seed" over the 64-byte seed. the left 32 bytes are the private key and the right 32 are the chain code, extra entropy that every child derivation mixes in.
kw_hmac_sha512((const uint8_t *)"Bitcoin seed", 12, seed, seedlen, I);
a child is hmac-sha512 under the parent's chain code, whose left half is added to the parent key mod the curve order. a hardened child (index at or above 231, written with a ') hashes the parent's private key; a normal child hashes the parent's public key, which is why an extended public key can walk normal children and never hardened ones.
source: crypto/bip32.c · crypto/ec.c
a path (bip44)
m / 44' / 3' / 0' / change / index
| | | | └ the nth address
| | | └ 0 for receive, 1 for change
| | └ account
| └ coin type: dogecoin is 3
└ purpose: this layout
the last two levels are normal, so kw export xpub gives the account public key and kw scan --xpub can derive every address the account will ever use with no private key in the process. that also says what an xpub costs: it cannot spend, but whoever holds it sees every address, past and future, and can tie them together.
source: crypto/bip44.c
an address
take the 33-byte compressed public key, hash it with sha-256 then ripemd-160 (together, hash160), prefix the version byte 0x1e, and append the first four bytes of a double sha-256 as a checksum. base58 the 25 bytes and the version byte makes the first character a D. the address is a hash of the key, so the key itself appears on chain only when the coin is spent.
source: crypto/address.c · crypto/base58.c · crypto/chainparams.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
a web page is not where a real mnemonic belongs. use test words. the twelve above are the standard test phrase, and kw derives the same address from them.
learn / 2
keystore
sealing a seed with a passphrase, 52 bytes of header at a time
the seed has to live on disk between runs. the keystore stretches a passphrase into a key with argon2id and encrypts the 64-byte seed under chacha20-poly1305. it stores the seed, not the words, and the words cannot be derived back from it.
offset size field
0 4 magic "DWKS"
4 4 version, kdf id, aead id, reserved
8 12 argon2 t_cost, m_cost_kib, parallelism
20 16 salt random per seal
36 12 nonce random per seal
48 4 ct_len
── the 52 bytes above are authenticated ──
52 n ciphertext
52+n 16 poly1305 tag
the default cost is three passes over 64 MiB. argon2id is memory-hard, so guessing passphrases on a gpu costs memory per guess rather than only arithmetic.
the header is the associated data
/* header is the AAD, so version and kdf params are tamper-evident */
kw_chacha20poly1305_encrypt(key, nonce, out, KS_HDR,
secret, secretlen,
out + KS_HDR, out + KS_HDR + secretlen);
an aead authenticates bytes it does not encrypt. passing the header as that data means someone who edits the file to lower the argon2 cost, or swap the salt, gets a tag that no longer verifies. without it, an attacker with write access could downgrade the kdf and wait for you to re-seal.
check before the expensive part
/* before the KDF runs, since the tag is only checkable after it */
if (t > KW_KEYSTORE_MAX_T_COST || m > KW_KEYSTORE_MAX_M_COST_KIB ||
p > KW_KEYSTORE_MAX_PARALLELISM) return 0;
there is an ordering problem in the design above: the header's integrity can only be checked after the key is derived, and deriving the key runs whatever cost the header asks for. a crafted file asking for a terabyte would be honoured before the tag could refuse it. so the parameters are bounded first, and the length field has to match the file size exactly. a wrong passphrase and a tampered file both end the same way, a failed tag and nothing decrypted.
the derived key is mlock'd while it exists and wiped after, including on a failed derivation, since "a failed kdf can still have written". how that locking works is the next chapter.
source: crypto/keystore.c · crypto/aead.c · crypto/kdf.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
kw seals with t=3 and 64 MiB. these defaults are lighter so the page stays quick; raise them and watch it slow down.
learn / 3
memory
zeroing that the compiler cannot remove, mlock that counts, no core files
a decrypted seed is a run of bytes in ram. the job is to keep it out of every place ram leaks to: freed memory reused by something else, swap, core files, other processes. each defence is a few lines, and each has a way to be quietly wrong.
zeroing the compiler cannot remove
void kw_secure_zero(void *p, size_t n)
{
volatile unsigned char *v = (volatile unsigned char *)p;
while (n--) *v++ = 0;
}
a memset on a buffer that is never read again is a dead store, and optimisers delete dead stores. writing through a volatile pointer tells the compiler each write is observable, so it stays.
comparing without a timing leak
for (size_t i = 0; i < n; i++) d |= (unsigned char)(x[i] ^ y[i]);
return d; /* 0 == equal */
memcmp returns at the first difference, so how long it takes says how many leading bytes matched. this loop always touches every byte. kw backup verify uses it to compare the seed your words give against the keystore's.
mlock, counted
mlock pins pages so they never reach swap, but it works on whole pages and does not count. lock two secrets on one page, unlock the first, and the second is pageable while still in use. that happened: the keystore's derived key sits a stack frame below the caller's seed, and forgetting the key unlocked the seed.
if (--lock_tab[i].refs == 0 && !lock_overflow) kw_munlock((void *)pg, ps);
so koinu keeps a reference count per page and unlocks on the last forget. if the table overflows it stops unlocking altogether, since an untracked page could otherwise be released under a secret it does not know about. what gets locked is the seed, every bip32 key on the derivation path including the intermediates, the hmac output a child key is cut from, the keystore key, a decoded wif, and the buffer a passphrase is read into.
what is not locked, said plainly
argon2id allocates its own working memory, up to the 1 GiB a keystore header may ask for, and bip39 builds "mnemonic" plus the passphrase on the heap for the length of 2048 pbkdf2 rounds. both are wiped before they are freed, but locking a gibibyte would fail under the usual RLIMIT_MEMLOCK. on a machine with swap under memory pressure, key derivation is the window where passphrase-derived bytes can reach disk. the threat model states this rather than claiming otherwise.
core files and stdio
setrlimit(RLIMIT_CORE, &rl); /* zero */
prctl(PR_SET_DUMPABLE, 0, 0, 0, 0); /* and no ptrace from a same-uid process */
mlock keeps a page out of swap and says nothing about a core dump. a SIGQUIT at the passphrase prompt once put a mnemonic the user had not seen yet into a core file, and systemd-coredump keeps what it is given. kw, kwd and kwui set both of these before reading any secret. the mnemonic is written with write(2) rather than printf too, because stdio's buffer is malloc'd once, never wiped, and outlives every forget in the process.
source: crypto/mem.c
learn / 4
transactions
what a legacy signature commits to, and the amount it does not
a transaction lists inputs, each naming a previous output by txid and index, and outputs, each an amount and a locking script. a p2pkh locking script says "show a public key hashing to this, and a signature by it":
OP_DUP OP_HASH160 <20-byte hash160> OP_EQUALVERIFY OP_CHECKSIG
76 a9 14 ... 88 ac
what gets signed
a signature cannot cover its own scriptSig, so legacy signing serializes a modified copy: every input's scriptSig empty except the one being signed, which carries the previous output's script instead. append the hash type as four bytes and double sha-256 the lot.
} else if ((size_t)sig_index == i) {
w_varint(&w, sublen); /* the input being signed: its scriptCode */
w_bytes(&w, subscript, sublen);
} else {
w_varint(&w, 0); /* every other input: empty */
}
...
buf[n++] = (uint8_t)hashtype; buf[n++] = 0; buf[n++] = 0; buf[n++] = 0;
kw_hash256(buf, n, out);
the signature is deterministic (rfc 6979, the nonce derived from the key and the message) and low-s, so signing twice gives identical bytes and a nonce is never reused. the scriptSig is then <sig || 0x01> <pubkey>. the result is checked byte for byte against libdogecoin's signer on 322 real mainnet spends.
source: crypto/tx.c
the amount that is not signed
look at what that preimage contains: the outpoints being spent and the scripts they carry. it does not contain what those outpoints are worth. legacy sighash commits to the inputs, not their values, so a signer has to be told each amount, and the fee is whatever the inputs hold minus the outputs.
the consequence is concrete. editing one value column in a utxo file turned a 1000 doge spend into a 998 doge fee that the wallet reported as 0.000192. kw send could not catch it either, since the fee is not in the transaction to be read back, and a fee bound cannot help because it bounds the fee the signer believes it is paying.
/* everything said about the transaction is checked against bytes that
produce this txid */
kw_hash256(rawtx, rawlen, got);
if (memcmp(got, txid, 32) != 0) return 0;
so the utxo set carries the whole previous transaction behind every output, and loading it hashes those bytes, requires the txid, and reads the amount and script from them. a hostile file can still name a coin that does not exist, which produces a transaction nodes refuse. it can no longer move value to the miner. a psbt works the same way, which is what makes the air-gapped signer safe to hand a file.
source: wallet/utxo.c · crypto/psbt.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
a real mainnet spend from test/vectors/sighash_mainnet.txt, which the network accepted. the sighash is the 32 bytes the signature in its scriptSig signs. notice that no amount for the input appears anywhere above.
a lesson from OP_CODESEPARATOR
consensus hashes the scriptCode from just past the last OP_CODESEPARATOR executed before the checksig, then removes any that remain. sign the script verbatim and you get a signature that verifies locally against the same wrong hash and fails on chain. two bugs were fixed here: dropping every 0xab byte, including one inside a pushed hash160 (one address in thirteen has that byte), and starting from the last separator anywhere rather than the last before the checksig.
/* OP_CODESEPARATOR is an opcode, and 0xab inside a push is data */
if (datalen) { memcpy(out + k, in + i, hdr + datalen); ... continue; }
if (op != 0xab) out[k++] = op;
which separator executes depends on the run, so where a branch could skip one, or one sits between two checksigs, the bytes do not say what to sign. koinu refuses those rather than guessing. the general point: a signer must hash exactly what a node will, and a test that verifies against your own hash function proves nothing about that.
multisig
a p2sh output locks to the hash160 of a redeem script, here OP_m <pub>... OP_n OP_CHECKMULTISIG, and the spend reveals it. the scriptSig starts with OP_0 because CHECKMULTISIG pops one more item than it uses, a bug from bitcoin's first release that is now consensus. cosign and psbt sign refuse a script that does not name the signing key, which closed a hole where a p2pkh script passed off as a redeem script turned a cosignature into a spend of the signer's own coin.
learn / 5
shares
splitting a seed so that fewer than a threshold is nothing at all
one written phrase conflates two numbers: one copy is lost to one fire, and one copy is stolen from one drawer. make two copies and you halve the first risk by doubling the second. threshold sharing separates them.
shamir, in one paragraph
to split a secret s with threshold t, pick a random polynomial of degree t−1 whose constant term is s, and hand out its value at distinct points. any t points determine the polynomial and so s. with t−1 points every value of s is equally consistent with what you hold, so fewer than the threshold reveals nothing, and that is arithmetic rather than a claim. the field is finite, so there is no size or rounding leak either. losing the wallet takes more than n−t shares, stealing it takes t.
slip-39
koinu splits the 64-byte seed, which is what slip-39 calls the master secret, so a threshold restores the same wallet in any implementation of the standard. groups add a second level: a threshold of groups, each with its own threshold of shares. slip-39 also encrypts the secret under an optional passphrase, and has nothing to check that passphrase against by design, so a wrong one combines to a different wallet. a share leaks the backup's shape (its group, thresholds and group count are in clear) and none of the seed.
combine refuses a set with a spare share. a set that reconstructs with one left over does not tell you what it reconstructed.
source: crypto/slip39.c
codex32
bip-93 does the same split over gf(32), the field of 32 elements, and checksums each share with a bch code over that field. both are small enough to compute with a pen and a printed table, which changes who can check a backup: you can verify a share, or recompute a lost one, without trusting the machine that reads it.
it gives up slip-39's passphrase, its digest over the reconstructed secret and its grouping. individually valid shares reconstruct whatever polynomial they describe, and nothing in the format says that is the seed they came from. that is why split prints a fingerprint and combine prints the first address: compare them.
source: crypto/codex32.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
learn / 6
headers
scrypt, nBits, work, and dogecoin's off-by-one retarget
a light wallet does not download the chain. it downloads the 80-byte headers, checks the work in each, and later asks for the few blocks it cares about, checking each against the header that commits to it.
offset size field
0 4 version
4 32 previous block hash
36 32 merkle root of the block's transactions
68 4 time
72 4 bits (the target, compact)
76 4 nonce
the block's identity is the double sha-256 of those 80 bytes. its proof of work is a different hash: scrypt with n=1024, r=1, p=1 over the same bytes, which has to come in at or under the target. koinu's scrypt has scalar, sse2, neon and an eight-at-once avx2 salsa20/8 core, each checked against the scalar one over random headers.
source: net/headers.c · crypto/scrypt.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
dogecoin's genesis block. its double sha-256 is the genesis hash chainparams compiles in, and its scrypt hash meets the target its own bits ask for.
nBits
the 256-bit target is stored as an exponent byte and a 24-bit mantissa: target = mantissa × 256exponent−3. the top mantissa bit means negative, which a target never is. decoding has a trap the comment spells out: bounding the exponent is not enough, because 0x22001234 has a small exponent for its mantissa and still runs past bit 256.
unsigned shift = 8 * (expo - 3), mbits = 0;
for (uint32_t m = mant; m; m >>= 1) mbits++;
if (shift + mbits > 256) return 0;
work
a header's work is the expected number of hashes to meet its target, 2256 / (target + 1). the chain with the most total work wins, not the longest. 2256 does not fit in 256 bits, so the code uses an identity:
/* 2^256/(t+1) without a 257th bit: (~t)/(t+1) + 1,
since 2^256 = (~t) + (t+1) */
for (int i = 0; i < 8; i++) num.w[i] = ~target.w[i];
divide(&num, &denom, out);
kw_u256_add(out, &one);
the 256-bit integers are eight 32-bit limbs with exactly the operations the two rules need, and the division is plain shift-and-subtract because it runs at most once per header.
source: net/pow.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
the retarget, and its off-by-one
a header's target is not its own claim. it must equal what the retarget rule derives from the headers before it. dogecoin retargeted every 240 blocks (four hours at a minute a block) until digishield at 145000, and every block after, with an amplitude filter that moves only an eighth of the way toward the measured time and clamps the result:
actual = span + (actual - span) / 8; /* the amplitude filter */
lo = span - span / 4;
hi = span + span / 2;
the switch has a subtlety worth reading the code for. whether a height retargets uses the previous block's height, so 145000 still belongs to a 240-block period, and since 145000 mod 240 is 40 it retargets nothing. the damping and timespan use the height being validated. so 145001 is the first block that actually recomputes every block. get that wrong by one and the header chain stops at 145000.
static int per_block(const kw_pow_rules *r, uint32_t height)
{ return height >= 1 && (height - 1) >= r->digishield_height; }
static int digishield(const kw_pow_rules *r, uint32_t height)
{ return height >= r->digishield_height; }
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
presets are the real values from test/test_pow.c. change the times to see the amplitude filter and the clamp at work.
time
the retarget reads timestamps, so timestamps are the attack surface. without bounds, a run of headers claiming the maximum easing walks the target to the limit, after which a header costs nothing. so a header's time must be after the median of the eleven before it and no more than two hours ahead of local time. with those bounds the cheapest fake run of six confirmations still costs about 2.7 blocks of real work, which is the number to budget against rather than six.
one fix here is a nice small lesson: now is read per batch rather than once, because a sequential sync from genesis takes longer than two hours, and a single now taken at the start made honest headers near the tip look stamped in the future.
source: net/sync.c
anchors and the cache
below the newest block hash compiled into chainparams, the parallel download checks that each segment links and ends on its anchor, and checks no work. a hash names one chain where work only proves energy went into some chain, and the anchors are only as good as the release that carries them. --validate-pow trusts no anchor and hashes from height 1.
the cache stores each header with its hash so a load does not rehash six million records. that only proves each record agrees with the next one's claim, so the tip, every anchor height and every record above the newest anchor are rehashed: 20 ms on the machine this was measured on, against 8.5 s there for all of them with sha-ni unavailable. a block body is refused unless its transactions hash to the header's merkle root, with a level holding an equal adjacent pair refused too (cve-2012-2459: an odd level repeats its last node, so two transaction lists can share a root).
learn / 7
auxpow
checking work borrowed from another chain
since 2014 dogecoin is merge-mined with litecoin. a miner builds a litecoin block whose coinbase commits to a dogecoin block hash; if the litecoin header meets dogecoin's target, that work counts for dogecoin too. a dogecoin header with version bit 0x100 set is followed by an auxpow proof instead of carrying its own nonce, and its own header hash need not meet any target.
checking a proof is a chain of "this hash really is inside that". each step is a few lines of net/auxpow.c:
- the parent's coinbase must be transaction 0, and the parent must be on a different chain: its chain id (the high 16 bits of its version) cannot be dogecoin's
0x0062, or the work is not borrowed at all. - walk a merkle branch up from this dogecoin block's hash to a root. that root, byte-reversed, has to appear in the coinbase's first input script.
- hash the coinbase to its txid and walk the parent's merkle branch up to the parent header's merkle root. litecoin has segwit, so the txid has to be computed with the witness stripped; hashing the bytes as they arrived gives the wtxid, and every litecoin block since 2017 would be refused.
- the magic
fa be 6d 6dmust appear once and sit immediately before the commitment, otherwise a second chain's root could be appended and the same work spent twice. older proofs carry no magic, so the commitment must start in the first 20 bytes. - after the root come the tree size and a nonce. the size must be 2branch length, and this chain's slot must be the one a fixed formula derives from the nonce and the chain id, so one parent cannot commit to two dogecoin blocks in different slots.
- finally, scrypt of the parent header must meet the dogecoin header's own target.
/* Deliberately the same wrapping arithmetic as the reference: it overflows,
and the result is taken modulo a power of two, so the overflow is part of
the answer rather than a bug in it. */
rand = nonce;
rand = rand * 1103515245u + 12345u;
rand += (uint32_t)chain_id;
rand = rand * 1103515245u + 12345u;
return rand % (1u << h);
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
the parser also refuses non-canonical varints. the coinbase bytes are its txid's preimage, and two encodings of one length would be two txids for one transaction. the layout lives in one function, and a sync that is not verifying steps over a proof by calling the same parser with no destination, so the two can never disagree about where a proof ends.
learn / 8
filters
finding your coins without telling a peer which they are
with headers verified, the wallet has to find which blocks pay or spend its scripts. asking a peer "what about these addresses" tells the peer exactly which addresses are yours. bloom filters (bip37) were meant to blur that and leaked far more than intended. bip157/158 reverses the direction: the peer sends a compact summary of each block and the wallet tests it locally.
a golomb-coded set
for each block, hash every output script and every spent script with siphash-2-4, keyed by the first 16 bytes of the block hash, and map each into [0, N×M) where N is the item count and M is 784931:
/* the high half of the 128-bit product: uniform into [0, F) */
return (uint64_t)(((__uint128_t)v * (__uint128_t)F) >> 64);
sort the values and store the gaps between them. gaps in a uniform random set are roughly geometric, which golomb-rice coding packs well: the quotient in unary, the low 19 bits as they are.
to match, hash your own scripts the same way, sort them, and merge against the decoded stream. a false positive happens about once in 784931 per script tested, and costs one unnecessary block download. there are no false negatives.
val += gr_next(&b); /* next value in the set */
while (ti < nitems && tv[ti] < val) ti++; /* advance our sorted targets */
if (ti < nitems && tv[ti] == val) found = 1;
two defensive lines are worth noticing. a claimed count larger than the remaining bits is refused, since each element costs at least one bit, which also keeps N×M under 264. and the count is read into a uint64_t out-parameter rather than returned as a long, because long is 32 bits on i386 and truncation turned an absurd N into a small one that passed the bound.
source: net/gcs.c · crypto/siphash.c
run it koinu's own c, compiled to wasm, running in this page. nothing is sent anywhere.
block 49291 from bitcoin core's bip158 test vectors (blockfilters.json, bitcoin testnet). the filter rules are identical on dogecoin. click any of the block's scripts to test it, or paste your own.
trusting a filter
a peer could serve a filter that omits your transaction. so filters are chained the way headers are: each filter header is the double sha-256 of the filter's hash and the previous filter header.
memcpy(buf, filter_hash, 32);
memcpy(buf + 32, prev, 32);
kw_hash256(buf, 64, out);
koinu checks every filter against the peer's committed filter-header chain, pins the verified tip so a later sync has to connect to it, and checks the chain against anchors compiled into chainparams every 100000 blocks. above the last anchor the base is still the peer's word, and the anchors were generated from one node's cfheaders rather than derived from blocks.
source: net/cf.c · net/cfstore.c
what it leaks, and the catch
a peer still learns which blocks you fetched. that is weaker than an address query and not nothing, and tor moves the observer rather than removing them.
the catch is practical. probing the peers dogecoin's dns seeds hand out, 47 of 48 answered and none advertised bip158, the last time that probe was run. against the public network today the filter path does not run, and --spv, downloading every block in range and scanning it locally, is what is left. the filter backend needs a node you run with filters enabled. test/net_services.c is the probe if you want to measure again.
learn / 9
peers
choosing a chain when you cannot trust who serves it
everything so far checks what a peer sends. none of it helps if every peer you reach is serving the same wrong chain. choosing peers is the weakest part of any light wallet, and the useful thing is to know exactly how weak.
three chains, the heaviest wins
above the newest anchor, where chains can actually differ, a header sync opens up to three connections, asks each where its chain leaves the one already held, syncs each fork, checks each with its own validator pool so one peer's bad header does not discard another's chain, and keeps the one with the most work. the cached chain is a candidate too, so a peer has to beat it, not merely differ from it. a fork below an anchor is refused rather than weighed.
source: net/chainsel.c
a lie that switched the check off
comparing chains means holding them in memory, so above a cap the comparison is skipped and one peer is used. the span was sized from the highest height any peer advertised in its version message. one peer claiming 231 pushed the span past the cap and collapsed the comparison to peers[0], which could be that peer.
/* the lower median, so two peers take the smaller: with only two
there is nothing to outvote a liar with */
uint32_t med = h[(nh - 1) / 2];
a median moves only when most peers agree. the general lesson: any number a peer sends that changes which checks run is an input to the security of those checks.
tor, one circuit per peer
tor keys a circuit on the socks credentials. with none offered, every connection could share one exit, and that exit would see plaintext p2p for every peer the wallet treats as independent and could withhold from all of them. so each connection offers a fresh random username and password (rfc 1929). make net_tor opens three connections through a local daemon and reads circuit-status off the control port, so the circuits are counted rather than assumed.
source: net/socks5.c
what is still assumed
work is compared among the peers reached, not against the most-work chain in existence. three peers that agree can all be wrong, by collusion or by being one node behind three addresses. without --node, mainnet peers come from two dns seed hostnames in one domain, so whoever controls them or the resolver path picks every peer. anchors and work checks bound that to eclipse and censorship rather than forged history below an anchor, but above the newest anchor a chosen peer set is the whole input. a peer cannot invent a confirmation there (it would have to mine it); it can withhold one. for anything taking payment on a confirmation, pass --node more than once with nodes you chose.
learn / 10
boundary
a build that fails if signing code can open a socket
"the signer never touches the network" is easy to write in a readme and easy to stop being true. koinu turns it into something the build checks.
two archives
libkoinu-sign.a holds the keys, derivations, share formats, keystore, transaction builder, psbt code and utxo tracking. libkoinu-chain.a holds peers, headers, auxpow, difficulty and filters, and depends on the signing half rather than the reverse. the check reads what the linker will pull in, the archive's undefined symbols, rather than grepping source, so a call that arrives through a macro, an inline function or a new dependency is caught the same way:
SOCKETS='socket|socketpair|connect|bind|listen|accept|...|poll|epoll_wait'
BAD=$(undef libkoinu-sign.a | grep -xE "($SOCKETS)" || true)
the control
a check that finds nothing might be a check that cannot find anything. glibc versions its symbol references and mach-o prefixes every symbol with an underscore, and a pattern that misses both passes silently. so the script also requires the chain archive to reference some socket call, and fails if it does not, because then the first check proved nothing. after that it links and runs a signer against the signing half alone, which is the claim the symbol check only approximates. moving net/peer.c into the signing half fails on connect, setsockopt and socket.
GOOD=$(undef libkoinu-chain.a | grep -xE "($SOCKETS)" || true)
if [ -z "$GOOD" ]; then
echo "FAIL: libkoinu-chain.a references no socket call, so the check above"
echo " proved nothing."
the same script fails if the signing archive exports a name outside kw_ and KW_, or references stdout or stderr: the library prints nothing unless the caller installs a log sink, so a daemon or gui embedding it does not get koinu's prose in its output.
source: test/test_split.sh · Makefile
what it does not give yet
kw is one executable that links both archives, so the p2p parsers are in the binary that signs. for the program, what holds is a rule per invocation: new, restore, address, sign, backup and export never open a socket, scan forgets the seed and master key before it connects, and sweep is two commands for exactly this reason. the link boundary is what holds for a consumer of the library.
learn / 11
threats
what all of that defends, and what it does not
a summary of docs/threat-model.md, which is the document the readme deliberately does not try to be.
defended
- a stolen keystore without its passphrase: argon2id and an authenticated header
- secrets in swap, core files, argv, stdio buffers and freed memory, apart from the key derivation window stated in memory
- a compromised kwd signing anything: it holds no keys
- a hostile utxo file or psbt moving value to the miner: amounts are bound to txids
- a signature that verifies locally and fails on chain: codeseparator handling, and cosign checks every signature before assembly
- a peer forging history below an anchor, or headers above it without real work
- a peer rewriting filters it already served: the pinned filter-header chain
- one tor exit seeing every peer: a fresh credential pair per connection
- every parser that reads peer-chosen bytes has a fuzz target: transactions, psbts, p2p frames, headers, blocks and filters
not defended
- a compromised machine. a keylogger, a debugger, or code running as your user while kw runs can take the seed. no wallet can honestly claim otherwise.
- a wrong destination. koinu will faithfully sign a spend to an address you did not mean to type.
- approving a payment you meant to approve. it decodes and asks; a correct signature over a transaction you accepted cannot be undone.
- peers that all agree on a wrong chain above the newest anchor, or one node behind several addresses.
- a lost seed and keystore. there is no export, no account, no recovery address. that is the point of the thing, and it is also the way it bites.
not done: an external security review. everything here is self-assessed.
what it will not grow: server-side recovery, a login, telemetry, or any convenience that needs the key process to talk to the network.