How it works (deep dive)
The key exchange, wire format, on-card encryption, message authentication, randomness source, and known weaknesses. Read this if you want to evaluate the design rather than just use it.
Key exchange
The gadget creates a shared one-time pad by letting one MicroSD card make a round trip between two devices.
- A prepares a new contact. A's device writes fresh random bytes (
X_A) to/exchange/X_own.binon A's card. - A hands that card to B.
- B inserts A's card into B's guest slot. B's device reads
X_Ain chunks, generates matching random bytes (X_B), XORs them, and writes the result asOTP.binon A's card. - B's device also saves B's encrypted split of the pad to B's own card.
- A takes A's card back. A's device verifies the
OTP.binchecksum, saves A's encrypted split of the pad, and wipes/exchange/.
The full 10 MB pad is never held in RAM at once. It is processed in chunks, with a SHA-256 digest stored in the OTP.bin header so A can verify what comes back.
The pad is split by role:
- A gets the first 5 MB as
pad_send.binand the second 5 MB aspad_receive.bin. - B gets the reverse.
That mirror matters: bytes A consumes from pad_send are the bytes B consumes from pad_receive. No byte is meant to be used in both directions.
Wire format
Every message is shown as uppercase hex, both in the QR code and in the manual-entry fallback. The hex decodes to this byte frame:
[offset (4 bytes)] [length (2 bytes)] [ciphertext (N bytes)] [MAC tag (8 bytes)]
- offset — big-endian
uint32, the starting byte in the receiver'spad_receivefile. - length — big-endian
uint16, the ciphertext length in bytes. - ciphertext — plaintext XORed with the matching pad bytes.
- MAC tag — HMAC-SHA256 truncated to 8 bytes.
The sender uses their pad_send bytes. The receiver uses the mirrored pad_receive bytes. The frame carries the offset so messages can arrive out of order.
Messages are capped at 500 bytes of plaintext. At that size, the on-board QR encoder produces a code that still fits the 320 px screen with about 3 px per module.
SD card encryption
The own card has three regions:
/device/— plaintext card identity and KDF parameters./exchange/— plaintext temporary key-exchange staging, wiped after exchange./secret/— encrypted pads, contacts, settings, and bookkeeping.
Everything under /secret/ is encrypted with AES-256-CTR using a two-key design.
KEK = PBKDF2(PIN || device_secret, salt=card_salt)
DEK = random 32-byte master key generated once at card init
master_key.enc = DEK encrypted under the KEK
On every boot: enter PIN → derive KEK → decrypt master_key.enc → get DEK → use DEK for every other /secret/ read or write.
Changing the PIN re-wraps the small master_key.enc blob only. Pad files are not re-encrypted.
Wrong-PIN detection uses verify.bin: a known plaintext encrypted under the DEK. If decrypting it does not produce the expected value, the PIN or device_secret is wrong and the rate limiter increments.
A second wrapping of the DEK, recovery_token.enc, is stored on the card under a key derived from device_secret. That is what makes PIN recovery possible if you saved your device-secret backup.
The PBKDF2 iteration count is stored per card at /device/kdf_params.json, so future firmware can raise the default for new cards without making old cards unreadable.
Auto-lock is part of the security model. After 5 minutes without touch input, the gadget zeroes the DEK from RAM, clears in-memory message history, and returns to PIN entry.
Message authentication
Each message carries an 8-byte HMAC-SHA256 tag. The MAC key is another 8 bytes from the pad, consumed right after the ciphertext bytes.
Sender:
mac_key = pad_send[offset+length : offset+length+8]
tag = HMAC-SHA256(key=mac_key, msg=offset || length || ciphertext)[:8]
Receiver:
mac_key = pad_receive[offset+length : offset+length+8]
expected = HMAC-SHA256(key=mac_key, msg=offset || length || ciphertext)[:8]
Each message consumes len(plaintext) + 8 pad bytes: the ciphertext bytes plus the MAC-key bytes.
On the receiver side, the gadget does not need you to pick the sender first. It tries each contact's receive pad against the MAC tag; the right contact authenticates, the rest fail.
On mismatch the UI shows "Message authentication failed" and doesn't reveal the plaintext.
True random number generator
For v1, entropy comes from the RP2350's built-in hardware TRNG. That source is used for the random contributions that become the one-time pad and for other key material generated during setup.
A future hardware version may add a separate avalanche-noise entropy source and XOR it with the RP2350 TRNG output for defense in depth.
Known weaknesses
The design is intentionally narrow. The public security doc calls out these weaknesses.
Device plus card theft. If someone has both the device and the SD card, they may be able to read the device_secret from MCU flash with SWD/Picoprobe access, then brute-force the 4-6 digit PIN offline. PBKDF2 slows that down; hardware flash lockdown is deferred.
Unlocked device exposure. While the gadget is unlocked, the DEK and message history live in RAM. Auto-lock limits the time window, but it does not help if someone gets the unlocked device immediately.
Cold-boot attacks. RAM can retain data briefly after power-off, especially if chilled. The v1 security doc does not plan RAM zeroing on shutdown.
TRNG trust. V1 relies on the RP2350 hardware TRNG. If that source is biased or compromised, the one-time-pad security argument fails.
MicroSD forensic deletion. Used receive-pad bytes can be overwritten through the encrypted pad file, but MicroSD wear-leveling may leave old physical flash cells behind outside firmware control.
Guest-card exposure during exchange. B's device can read A's card while it is in B's guest slot. A's encrypted /secret/ data should remain inaccessible, but the plaintext /exchange/ staging area is exposed.
EM leakage. Electromagnetic side channels are out of scope for v1.
Next: Troubleshooting — common build and day-to-day problems.