# Nostr signing hardware: SNSD vs fapico2 — a systematic comparison

**Date:** 2026-11-10
**Revisions compared:**

| project | commit | language / platform |
|---|---|---|
| [nostr-signing-device](https://github.com/lnbits/nostr-signing-device) (SNSD, LNBits) | `37d7ef4` (2025-10-15) | C++ / Arduino, ESP32 (Lilygo T-Display) |
| fapico2 — `apps/nostr/` + `firmware/src/nostr_wire.rs` | `938327c` | Rust / no_std, RP2350 |
| [fapico2-nostr-extension](https://github.com/eddieoz/fapico2-nostr-extension) | `9245b36` (v0.5.0) | JavaScript / Chrome MV3, WebHID |

**Method:** every claim below was read out of the source, with `file:line` citations. Nothing is taken from marketing, READMEs alone, or other people's audits. SNSD's own README is quoted where it concedes a point, because an author's admission is the strongest evidence there is.

**Scope:** communication channel (serial vs WebHID), key custody, what a hostile host can do, cryptographic hygiene, consent surfaces. This is not a feature-count exercise; the question is *what happens when something goes wrong*.

---

## 1. The one-sentence versions

**SNSD:** a private key held as plaintext hex in external SPIFFS flash on a $5 ESP32, driven over an unauthenticated plaintext serial line — whose own README says the transport is "not the most secure data transmission method, but x1000 better than storing the privatekey on a computer."

**fapico2:** private keys sealed as AES-256-GCM records under an RP2350 OTP-rooted key that cannot be exported or read back, spoken to through a WebHID vendor interface in which the client never sees key material, every secret operation behind a durable-attempt PIN gate and a per-identity physical touch, and **no export verb exists at all**.

Both are real products. The difference is which side of the trust boundary each one puts the user.

---

## 2. Communication channel

### 2.1 SNSD: webdev serial, plaintext, unauthenticated

The transport is the Arduino `Serial` object at **9600 baud** (`snsd/800_setup.ino:24`, `Serial.begin(9600)`), consumed with `Serial.readStringUntil('\n')` (`snsd/701_events.ino:98`). Commands are plain ASCII text: `/sign-message <32-byte hex>`, `/decrypt-message <pubkey> <ciphertext>`, `/add-key <hex nsec>`, `/wipe` (`snsd/100_constants.ino:5-26`). Responses are `println`ed back the same way (`700_commands.ino:87-89`).

Properties of this channel, each one load-bearing:

1. **No pairing, no authentication, no encryption.** The pairing constant `PAIRING_CONTROL_TEXT = "lnbits"` exists (`100_constants.ino:2`) and is referenced nowhere else in the tree — pairing was declared and never built. Any process on the host that can open a serial port *is* the device's authority.
2. **Secrets cross the wire in both directions.** `/add-key` and `/restore` carry the raw hex private key host→device (`701_key_management.ino:8-60`); `/shared-secret` returns the raw ECDH shared secret device→host to any requester (`704_cmd_shared_secret.ino:26-36`); `/decrypt-message` returns the decrypted plaintext over the same line (`707_cmd_decrypt_message.ino:22`). A host-side compromise therefore harvests keys, secrets, and message content from the bus — the boundary the device exists to draw is drawn nowhere.
3. **The README concedes it.** "Data is sent to/from the **Signing Device** over webdev Serial, not the most secure data transmission method, but x1000 better than storing the privatekey on a computer" (README.md, §"How to use"). Note the baseline it claims: better than a plaintext file on the computer. Section 3 shows SNSD is, at rest, *also* a plaintext file.
4. **Serial is a command channel that outlives consent.** Web Serial permission is granted per-site by the browser, but the OS-level port is first-come-first-served: any local process (another user, a background daemon, malware) that claims `/dev/ttyUSB0` between browser sessions owns the device. There is no cryptographic channel for the device to know *who* is talking.
5. **Host-side friction is real too:** 9600 baud, CP210x driver installs on macOS (README install notes), and a "commands file" mode (`awaitFileEvent`, `701_events.ino:15-19`) that replays script lines from flash — a second, unattended execution path with the same authority.

### 2.2 fapico2: WebHID on a vendor usage page, framed, routed, in-band

The device exposes a **third HID interface** (usage page `0xFF00`, usage `0x01` — deliberately not FIDO's `0xF1D0`, so Chrome's WebHID blocklist never interferes and the two protocols cannot collide: `platform/src/nostr_hid.rs:3-15, 47-49`), and the extension speaks CTAPHID framing over it (`src/core/ctaphid.js:17-18`): 64-byte reports, channel-ID allocation via INIT on `FFFFFFFF` with nonce echo verification, length-checked reassembly with out-of-order sequence drop (`ctaphid.js:223-245, 272-286, 382-421`). Command bytes are `0x40`–`0x4F`; payloads are CBOR `[idx, arg]`; responses are CBOR `{1: sw, 2?: data}` with a full status-word vocabulary (`ctaphid.js:39-69, 189-216`).

Properties that matter:

1. **Routing by interface, pinned by test.** A frame on the Nostr interface never reaches the FIDO dispatch and vice versa (`firmware/src/hid_serve.rs:1541-1570`; pinned by `us1660_a_nostr_command_is_refused_loudly_and_never_reaches_fido`, hid_serve.rs:4031-4093). SNSD has one wire for everything; fapico2's wires are disjoint by construction.
2. **Errors are structured and in band.** An unsupported command answers `6D00` *inside the CBOR envelope on the same channel* — never a hang, never a framing fault, never a fallthrough (`firmware/src/nostr_wire.rs:122-124`). The extension maps every status word to a human sentence, including the PIN-attempt countdown and the two ceremony words (`src/worker/error-map.js:32-94`); unknown words are still typed, never dumped raw (`error-map.js:97-105`).
3. **The client cannot cheat the length.** `MAX_REQUEST_ARG = 1891` is refused *at decode*, before the applet runs (`nostr_wire.rs:87-95, 186-189`), and the chunk-rounded cap that makes it honest is compile-time-twin-tested (`apps/nostr/src/lib.rs:291-325`).
4. **WebHID's permission model is the browser's**, user-gesture-gated and revocable via `forget()` (`fapico2-nostr-extension/src/worker/device.js:168-179`), and the extension adds its own per-origin method grants on top (`src/worker/permissions.js`). SNSD has no equivalent layer at all.

**Verdict on the channel.** SNSD's serial line is an *authenticated-by-assumption* channel: it assumes the host is honest, and then builds key custody on that assumption. The whole architecture inherits the assumption. fapico2 treats the host as hostile by default — that is the only assumption a signing device is allowed to make — and puts every boundary (framing, capability, status, consent) inside the device.

---

## 3. Key custody: where the keys actually live

### 3.1 SNSD: plaintext hex, one file, external flash

`saveKeys()` (`snsd/400_helpers.ino:40-56`) writes the entire key list as **raw hex, one per line**, to a SPIFFS file; `loadKeys()` reads them back into a `std::vector<String>` in RAM. No encryption, no wrapping, no derivation — the bundled `tiny-AES-c` library is used exclusively for DM encryption (`500_crypto.ino`), never for at-rest protection. The same is true of the PIN itself: `savePIN` writes it as plaintext (`700_commands.ino:95-97`), and a missing PIN file yields the default `"00000000"` which the login screen treats as *unset, skip login* (`200_ui.ino:139-145`).

Consequences, concretely:

- **A flash dump is a key dump.** SPIFFS lives on the ESP32's external SPI flash, readable with `esptool.py read_flash` from the same USB port the device serves. No secure element, no fuse-rooted key, nothing to derive from: the ESP32 has no hardware root for at-rest sealing, and SNSD does not compensate for it. (For contrast, the RP2350's `otp_key_1` row is read-protected by construction and the keyregion refuses to derive anything at all from a zero or faulted row rather than falling back to a constant — `platform/src/keyregion/crypto.rs:68-88`, `platform/src/ckey.rs:170-172`.)
- **The PIN is not a key.** It gates the on-device UI loop only (`900_loop.ino:4-9`); it protects nothing at rest, and once entered it stays unlocked until power-off. And it is 8 fixed digits with a *writable* attempts counter file — the counter's durability is a file the device itself rewrites (`loadPIN`, `700_commands.ino:99-120`).
- **Export is the design.** Keys enter as hex over serial (`/add-key`, `/restore`) and the plaintext file is the storage. A "signing device" whose keys are trivially extractable has moved the nsec from one plaintext store (the computer) to another (the flash chip) while adding a transport that re-exposes it.

### 3.2 fapico2: sealed records, an OTP root, and no door out

The Nostr applet's eight identity slots live in the keyregion (`platform/src/keyregion/`), the same per-record store the FIDO applet uses — one format, one region, one key hierarchy. Each record is AES-256-GCM with an AAD that binds **slot, generation and key domain**, so a record copied to another slot or domain simply fails to open (`platform/src/keyregion/crypto.rs:100-116, 729-735`); on tag failure the plaintext buffer is zeroized before return (`crypto.rs:622-625`). The record key is `HKDF(HKDF(otp_key_1 ‖ chipid) ‖ …)` (`crypto.rs:3-16, 501-508`): the root is an RP2350 OTP row plus the chip ID, neither of which is exportable, and a faulted or virgin OTP row derives **nothing** — `None`, never a constant key (`crypto.rs:84-88`).

Three deliberate decisions sit on top:

1. **No export, anywhere, in any dialect.** INS `0x1A` is retired — the dispatcher has no arm and answers `6D00` (`apps/nostr/src/lib.rs:132-136`); vendor byte `0x48` is unmapped and answered in band, with a test that *panics if the applet is ever reached* (`firmware/src/nostr_wire.rs:56-61, 521-538`). Custody is import-only: a key can enter, never leave. SNSD's `/shared-secret` handout is the exact inversion of this.
2. **The occupancy guard.** A write to a non-virgin slot must open as a Nostr record first, or it refuses `6A84` — a stale or foreign record is never silently overwritten (`platform/src/keyregion/nostr_store.rs:578-630`).
3. **The custody ceremony.** Vendor `0x4E` burns OTP custody rows and reseats every sealed record off the burned key; after the marker stands, *no* record — including the PIN record — opens under the legacy derivation, and the window closes at boot (`apps/nostr/src/lib.rs:151-156, 1923-1972`; `firmware/src/boot.rs:677-713`). Downgrading a fapico2 board is destruction, not extraction.

**Verdict on custody.** SNSD stores the secret itself; fapico2 stores the secret *encrypted under a root the device cannot tell you*. These are not two points on one spectrum — they are different designs, and only one of them survives a flash dump.

---

## 4. What a hostile host can do

The channel and the custody decisions compound. Take a compromised host (malware with access to the port, a malicious page, or a second OS user) and ask what it can do to each device *without any user-visible event*:

| action | SNSD | fapico2 |
|---|---|---|
| learn the identity set | `/list-keys` prints every pubkey + label | needs `LIST_KEYS`/`GET_PUBKEY` — touch-free by design (pubkeys are public) |
| sign as the user | `/sign-message` — **one button press defeats it**, but the screen shows only a key preview, never the message or hash being signed (`704_cmd_sign_message.ino`) | SIGN requires the touch, bound to a per-identity tag (`0x4000_0000 ‖ idx` CCID, `0x6000_0000 ‖ CID` HID, disjoint from FIDO's `0x8000_0000`) |
| read decrypted DMs | `/decrypt-message` returns plaintext over the wire, no touch, no gate | `NIP44_DECRYPT` needs PIN session + touch; plaintext goes only to the consenting extension |
| obtain an ECDH secret | `/shared-secret` hands it over to any caller | no equivalent verb; NIP-44 encrypt/decrypt run entirely on device |
| extract a private key | flash dump, or reflashing, or — since the file is plaintext — any readback tool | impossible: no export verb, keys sealed under OTP root; the closest thing, a downgrade, destroys the records instead (ADR 0006) |
| destroy all identities | **`/wipe` over serial with no on-device confirmation** (`708_cmd_device_state.ino:20-27`: `SPIFFS.format(); ESP.restart();`) — the button-confirm flow exists only in the menu path (`203_ui_wipe_device.ino`) | management `RESET` wipes Nostr, but is itself a touch-confirmed management operation; CTAP2 reset spares Nostr |
| guess the PIN | 8 digits, default `"00000000"` = unset; attempts counter is a rewriteable file | durable retry budget, `63C0‖remaining` down to `63C0` lockout — "a budget a power cycle cannot refund" (`apps/nostr/src/lib.rs:168-171, 1530-1557`) |

The `/wipe` row deserves emphasis because it is the cleanest demonstration of the design gap: on a default (PIN-unset) SNSD, *any* host process can erase every identity with a one-line serial write and no user interaction. A signing device that its own host can silently erase is a signing device the host controls.

---

## 5. Cryptographic hygiene

| concern | SNSD | fapico2 |
|---|---|---|
| signature scheme | uBitcoin `schnorr_sign` (BIP-340) over a hex key held in a `String` | BIP-340 via secp256kfun, chosen on a measured RAM-lifetime spike (ADR 0005), with the two transcription traps handled and pinned (`apps/nostr/src/crypto.rs:87-122`) |
| signature randomness | — (deterministic Schnorr) | per-signature aux entropy drawn on-device (`lib.rs:1112-1116`) |
| key generation RNG | `esp_random()` byte-by-byte (`400_helpers.ino:115-122`) — the ESP32 HW RNG is serviceable but quality depends on boot/RF state | RP2350 TRNG block; scalar validity checked, 4 retries (`lib.rs:984-1008`) |
| **encryption IVs** | **`random(0,256)` — Arduino's PRNG, seeded once at boot** (`400_helpers.ino:123-127` vs `800_setup.ino:30`): every AES-256-CBC IV for the device's lifetime comes from one deterministic stream. IV predictability/reuse is a concrete confidentiality break for every DM the device encrypts | NIP-44 v2 implemented on device, HKDF conversation keys, constant-time MAC verify ordered **before** length/padding parsing (`lib.rs:1224-1230`) — fail-closed parsing |
| DM encryption standard | AES-256-CBC + zero padding (the legacy NIP-04 shape) with a `?iv=` string protocol | NIP-44 v2, the current Nostr standard, with chunk-rounded capacity caps so an oversized request fails loudly in band rather than producing a dropped frame (`lib.rs:291-325`) |
| signed data | 32-byte hex hash from the client (reasonable) | same shape — the extension computes the sighash in the worker, sends only 32 bytes (`fapico2-nostr-extension/src/core/seams.js:192-202`), then verifies the signature's pubkey echo (`seams.js:203-214`) |

The IV finding is not a nitpick. It means an SNSD's DM encryption degrades predictably over the device's lifetime, in a way no amount of careful key storage would have fixed, because the randomness architecture was never examined.

---

## 6. Consent surfaces (UX as a security property)

A signing device's UX *is* its security model made visible. What does each project show the user at the moment of decision?

**SNSD:** the sign screen shows the key label and nothing else — "Top to accept / Bottom to reject" (`704_cmd_sign_message.ino`). The user confirms blind: the message, its hash, the requesting client, and the identity's npub never appear. There is no consent layer on the host side at all — horse/Nos2X call the serial device directly. The unlock PIN (if set) is an 8-digit button-spinner entered once per boot.

**fapico2:** the consent surface is layered:

- the **extension prompt** shows host, method in plain words, event kind and a content preview — but *never* plaintext for nip44 ops, only peer + length (`src/worker/prompt.js:17-70`), and per-host method grants with session-only and prompt-window tiers (`src/worker/permissions.js`, `src/worker/consent.js`);
- the **device touch** is per-identity and per-connection (tag domains §4), so a press on the board cannot be replayed across identities or transports;
- the **PIN session** is RAM-only, spans both transports, survives re-select, dies with the power cycle, and a *failed* verify closes it and clears the signing bypass (`apps/nostr/src/lib.rs:1649-1737`);
- the **signing bypass** (US-1741..1746) is the one convenience valve, and it was built like a security mechanism: armable only from an open PIN session *plus a physical press*, RAM-only, consumed by refusals, cleared by LOCK/fail/power, with the re-enable direction deliberately friction-free so the button can always be had back (`lib.rs:1740-1788`).

The asymmetry is deliberate and worth naming: SNSD's only friction is the blind button press and the optional boot PIN, both of which are *screen-local*. fapico2's frictions are *protocol-local* — they live in the wire contract, so the client cannot skip them, forge them, or talk around them. That is why the §4 table's fapico2 column has "impossible" entries while SNSD's column has only "button press defeats it" entries.

The channel choice drives the UX choice, too. Serial-ASCII is why SNSD cannot send a structured per-request consent record to a browser prompt, and WebHID framing is why the extension can: typed envelopes, typed errors, previews computed in the isolated worker, a 1 MiB protocol cap, and a MessageChannel handshake so page-forged replies are dead letters (`src/shared/protocol.js:22`, `src/content/bridge.js:50-93`). The transport is not plumbing; it is what makes the consent layer *possible*.

---

## 7. Honest ledger: what each project does better

A comparison that lists only the other project's failures is not an audit. This section is the ledger both ways.

### 7.1 SNSD advantages

- **Price and availability.** A $5 off-the-shelf ESP32 with a display, flashed from the browser in one click. fapico2 needs an RP2350 board and a purpose-built firmware image.
- **On-screen UI.** SNSD ships a full touchscreen UI — menus, QR display, dark mode, key switching on the device itself. fapico2's board has an LED and a button; everything visible happens on the host, which is exactly the thing a signing device distrusts. (This is a real UX trade, not a point in our favor.)
- **Key portability, in both directions.** Keys import and (by omission of any protection) de facto export. A broken device loses nothing. fapico2's import-only, device-bound custody means **a dead board is a lost identity set** — recorded deliberately in ADR 0006 as "downgrade is destruction, not extraction," but it is a cost users will feel, and it is the honest price of the §3.2 guarantees.
- **Simplicity.** ~2.3k lines of Arduino. Every weakness in this document is visible in ten minutes of reading. A codebase you can fully audit has a security property fapico2's ~30k+ lines do not.

### 7.2 fapico2 residual weaknesses (recorded, not hidden)

From the extension-side analysis (`fapico2-nostr-extension` v0.5.0) and the firmware:

1. **Persisted grants are indefinite.** A host remembered for `sign_event` never prompts again (`src/worker/permissions.js:73-77`); combined with an armed touch bypass, a malicious page on a remembered host can sign continuously with zero user interaction until worker or board restart. No grant TTL, no per-sign mode. This is the highest-value residual and it is ours to fix.
2. **Preview truncation.** Content previews cut at 80 characters and tags are not rendered (`src/worker/prompt.js:50-55`) — a remembered grant can sign long, deceptive events the user never saw.
3. **The port-handshake race** is acknowledged in-code, not closed ("the honest limit, R-4", `src/content/bridge.js:53-55`).
4. **Touch-free identity enumeration** (`GET_PUBKEY`, `LIST_KEYS`) enables silent cross-site identity linkage with a remembered grant. By design — pubkeys are public — but a design worth re-examining.
5. **The bypass is invisible at prompt time.** The extension cannot tell the user "the touch you are (not) about to receive is currently bypassed" (`src/worker/pin_flow.js:78`).
6. **Nostr capacity is 8 identities** (`NOSTR_CAPACITY`, `platform/src/keyregion/mod.rs:202`) vs SNSD's unbounded list — bounded storage is a deliberate capacity-is-security choice, but it is a bound.

Each of these is a *sharable-trust* residual: they all live inside the fapico2 stack and are gated by the device touch or the PIN. None of them changes the §3-§5 conclusions, because none of them exposes key material or hands the hostile host an unauthenticated command surface. But they are the difference between "the stronger design" and "a finished design," and they are tracked as such.

---

## 8. Why fapico2 decided what it decided

Every architectural position in this comparison corresponds to a recorded decision, not an accident of implementation:

| decision | where recorded | what it buys, in this comparison's terms |
|---|---|---|
| Vendor usage page `0xFF00`, not FIDO's interface | `platform/src/nostr_hid.rs:3-15` | the Nostr channel cannot collide with, or be mistaken for, a CTAP2 device; SNSD's single shared wire is the failure mode this avoids |
| Routing by interface, never by payload | `firmware/src/hid_serve.rs:1519-1521`, US-1660 | a frame is its transport's authority — the 0x41-dual-homing class of bug is structurally excluded |
| Keys derive from `otp_key_1 ‖ chipid`, never stored raw | keyregion `crypto.rs:3-16, 59-66` | flash dump ≠ key dump; the exact property SNSD cannot have on ESP32 |
| No export verb, import-only custody | US-1699, ADR 0006 | the hostile host has nothing to ask for; SNSD's `/shared-secret` and plaintext file are the counterexamples |
| PIN gate *after shape checks, before state reads and touch* | `apps/nostr/src/lib.rs:1983-1992` | unauthenticated callers learn nothing, not even whether a slot exists; refusals are cheap and information-free |
| Durable attempt budget (`63C0‖n`) | `lib.rs:168-171` | guessing survives no power cycle — vs a counter that is itself a rewriteable file |
| RAM-only PIN session and touch bypass | `lib.rs:510-528` | every privilege dies with the power; nothing to steal from flash, nothing a reflash preserves |
| Per-identity tag domains, disjoint from FIDO | `lib.rs:2391-2427` | a touch consented on one transport for one identity is not replayable anywhere else |
| Chunk-rounded caps, in-band errors, typed SW map | `lib.rs:291-325`, `nostr_wire.rs:87-95` | oversized/malformed requests fail loudly on the client's own channel — no dropped frames, no hangs, no guessing |
| NIP-44 v2 on device, TRNG nonces | `apps/nostr/src/nip44.rs` | the SNSD IV finding (§5) is the concrete cost of leaving randomness to a convenience framework |
| The extension holds no keys, ever | `src/core/loudness.js:59-68` | `SoftwareSigner` exists for tests and is enforced to be "never a fallback" — the boundary SNSD's README concedes it cannot hold |

The thread through all of them is one assumption, stated in §1: **the host is hostile**. SNSD's architecture assumes the host is honest and then inherits every consequence; fapico2 assumes it is not and spends its complexity budget exactly where that assumption is tested — the wire, the seal, the touch, and the PIN. That is also the answer to "is the added complexity worth it": every mechanism above names the attack it stops (§5, AGENTS.md rule 2), and the one mechanism that could not — the three-backend storage accretion — was deleted rather than defended.

---

## 9. Conclusion

SNSD is a legitimate project doing something genuinely hard — bringing hardware signing to Nostr at commodity prices — and its button-confirm and on-device UI are real. But its own README states its ceiling: the transport is serial, unauthenticated, and "not the most secure," the keys at rest are the plaintext file it claims to improve upon, the default PIN policy is *off*, the IV randomness is a boot-seeded PRNG stream, and the wipe verb can be fired by any host process with no confirmation.

fapico2 does not share a single one of those ceilings, because each one was closed by a decision with a name: the interface-routed vendor HID, the OTP-rooted sealed keyregion, the retired export verb, the durable PIN budget, the per-identity touch domains, the consented RAM-only bypass, and a client that structurally cannot hold a key. The residual weaknesses in §7.2 are real and tracked — but they are all inside the trust boundary the device draws, whereas every SNSD weakness catalogued here is *the* trust boundary.

The persuasive sentence, if one is wanted: **SNSD moves the private key off the computer and onto a device the computer fully commands; fapico2 moves the computer's authority over the private key onto the device, and then locks the device.**

---

*Sources: `snsd/*.ino` at `37d7ef4`; `apps/nostr/`, `firmware/src/`, `platform/src/` at `938327c`; `fapico2-nostr-extension` v0.5.0 (`9245b36`). Cross-references: [nostr-presence-audit.md](nostr-presence-audit.md), [ADR 0005 — Nostr key storage](adr/0005-nostr-key-storage.md), [ADR 0006 — Nostr PIN custody](adr/0006-nostr-pin-custody.md), AGENTS.md §5 and §8.*
