Two cards tell you nothing
Keyfob splits a recovery phrase into guardian cards with Shamir sharing over the AES field. Below the threshold every phrase stays equally likely, and a test proves it from outside the app.
Keyfob is a small Android app that turns one BIP-39 recovery phrase into guardian cards, so that, by default, any three of five rebuild the phrase and any two rebuild nothing. The arithmetic is Shamir secret sharing over the field of 256 elements, one byte at a time, and the title is exact: below the threshold, every possible phrase is as likely as it was before the cards existed. Building it taught me that for a tool like this the test is the product. A guardian of a 24-word phrase holds 27 words and has to take it on trust that they are useless alone and sufficient in company, so the repository carries a proof that does not trust the app, and the card format is where every usability decision has to pass through the arithmetic.
A curve through the secret
Shamir's scheme is from a two-page paper of 1979, How to share a secret, in Communications of the ACM 22(11), pages 612 to 613, doi 10.1145/359168.359176, cited through Wikipedia. Treat the secret as a number, choose a random polynomial whose constant term is that number, and hand each guardian one point on it. Any k points fix a polynomial of degree at most k minus 1, and its value at x equals zero is the secret; with fewer than k, in Wikipedia's words, "no information about the secret can be gained from any number of shares below the threshold". That is a stronger promise than encryption makes: an encrypted file is a puzzle that a faster computer gets closer to, and two cards of a three-of-five plan are consistent with every possible phrase.
Keyfob does this in GF(2^8), the field AES uses. web/js/shamir.js reduces modulo x^8 + x^4 + x^3 + x + 1, equation 4.3 in section 4.2 of FIPS 197, whose current revision is dated 9 May 2023 (read on 12 October 2026). It is also the field of SLIP-0039, the SatoshiLabs mnemonic standard, which applies the scheme "separately to each byte of the shared secret" and caps groups and members at 16 each; Keyfob's card format is its own. Working byte by byte means a share is exactly as long as the secret: 32 bytes for the entropy behind a 24-word phrase.
Log tables for the field are built by multiplying repeatedly by a generator, and the obvious choice, 2, is not one here: doubling walks a cycle of only 51 elements, so the tables are built by multiplying by 3, which visits all 255 non-zero elements once; I confirmed both orders on 12 October 2026. split draws fresh random coefficients for every byte position from crypto.getRandomValues, and combine interpolates at x equals zero and refuses a card numbered 0 or two cards with the same number.
The proof does not trust the app
The file that matters most is test/00-crypto-proof.js, and its first rule is that nothing in it trusts the app's own word. It starts from a known 24-word phrase, beginning congress, stadium, chest, whose 256 bits of entropy are checked against the phrase with Node's own crypto module, BIP-39 checksum included. It carries its own GF(256), written from the field definition, its own Lagrange interpolation, and a readCard that reads a card the way the estate document tells a stranger to: eleven bits per word, card number first, SHA-256 over the body, compare two bytes.
Then it does everything twice. The app's module splits the phrase three of five into five cards of 27 words. Node, on its own, decodes all five and interpolates each of the ten subsets of three: all ten return the entropy. It interpolates each of the ten pairs: none does. The same twenty subsets then go through the app's own fromCard and combine, and the 24 words have to come back, word for word, from every triple and no pair. It also runs the module's selfTest at 400 rounds rather than the default 120, and refuses to pass unless at least 1,600 reconstructions were checked.
Two cards do not fail loudly. A line through two points still crosses zero somewhere, so a pair returns 32 bytes that make a 24-word phrase with a valid checksum. In my Node run on 12 October 2026, cards 1 and 2 gave a phrase beginning soon, refuse, uniform, derive, and nothing in it says it is wrong. That is what "no information" looks like, and it is why the gather screen waits for the threshold number of cards before calling combine, then checks the result against a four-byte fingerprint of the original phrase.
One word of card 1 swapped for another real word: refused, and by the checksum, not by accident. The same card with its last word missing: refused, told that a card for this size of phrase is 27 words and you entered 26. A word not on the list: refused, naming the position. Finally the test drives the real screens in a headless browser, through a harness outside the repository: it types the phrase, accepts the default three of five, counts 27 words on each card, then types cards 1, 3 and 5 into the gather screen and reads the 24 words off the result.
Where the words meet the arithmetic
Nobody copies 32 bytes out by hand, so the card format is where the usability decisions live. Keyfob writes a card in the same 2048 words a recovery phrase uses. BIP-39 fixes the packing, eleven bits per word with each word an index from 0 to 2047, and its list is built so that the first four letters are enough, which lets the app finish a word as you type. The list in web/js/bip39.js has 2048 words with 2048 distinct four-letter prefixes, and it lives in the file because the app declares no internet permission.
The payload is five bytes around the share: the card number, the secret length, one byte holding the threshold and the total (m minus 1 in the high nibble, n minus 1 in the low, which is where the limit of 16 comes from), the share bytes, then the first two bytes of SHA-256 over everything before them. Packed eleven bits to a word and padded with zeros to a whole word, a card for a 24-word phrase is 27 words, the ceiling of (32 + 5) × 8 / 11.
Two of those decisions are worth defending. The card number goes first so that two cards from one set differ from their first word, which is what stops a guardian copying out the wrong one; for a 24-word phrase, card k always opens with the word at zero-based index 8k + 1, so the five cards in my run began abuse, acquire, add, aerobic and ahead. The two-byte checksum is sixteen bits, so a damaged card slips through by luck once in 65,536 tries, and it costs one word on a 27-word card, two on the three shortest sizes. It is what turns "something is wrong with these cards" into "every word is real, but the card fails its own checksum". The reader accepts only the five BIP-39 secret lengths, so random real words decode to nothing.
Keyfob is not a wallet: it never sees an address, a balance or a transaction, and the manifest in the Keyfob repository asks for one permission, VIBRATE, with backups switched off and no INTERNET permission. The whole app is plain HTML, CSS and JavaScript in web/, no build step, no dependencies, inside a thin Kotlin WebView shell. All of this is true of the repository at its last commit, 7 September 2026, read on 12 October 2026.
The test is the product
Nobody will ever see two cards tell them nothing; the only observable event is a rebuild, and a rebuild looks the same whether the pairs are safe or not. If the random coefficients were weak, or a field bug made some pairs informative, every screen would still render and every drill would still pass. In the repository the proof is the second of two commits, two days after the screens, and it should have been the first file. For a tool whose purpose is a property you cannot see, the test is not a quality step after the product. It is the product, and the app is the part a guardian can hold.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS