Skip to content
🔧 The developer beta is live — free for developers. Flash your own ESP32-S3 and test it. Learn more →

HID Protocol

The dongle speaks USB HID and nothing else. No COM port, no driver, no vendor software — on Windows, macOS and Linux it enumerates as a vendor-defined HID device and any HID library can talk to it.


VID / PID 0x303A / 0x8123
Report size 64 bytes, both directions
REPORT_ID 1 — mandatory, see below
Usage page vendor-defined (0xFF00)

arduino-esp32 has a documented bug (espressif/arduino-esp32#9288) by which OUT reports never reach the firmware on devices without a report id. Every client must send and expect the byte 0x01 in front of every report. Most HID libraries prepend it for you when you pass a 65-byte buffer; some do not. If the dongle answers CMD_PING but ignores everything else, this is why.


Every command and every response has the same shape.

OUT (host → dongle)

byte 0 command opcode
byte 1 length of the argument that follows (only for commands that take one)
byte 2.. argument, zero-padded to 63 bytes

IN (dongle → host)

byte 0 the command opcode, echoed back
byte 1 status code — or a sequence number, for CMD_CHALLENGE only
byte 2.. response payload, zero-padded to 63 bytes

Byte 0 is echoed on every reply, including errors and unknown opcodes. Check it before trusting byte 1. A reply whose opcode is not the one you asked for is a stale report still sitting in the queue, not an answer to your command — discard it and read again.


Command Opcode PIN required Argument Response
CMD_PING 0x01 no — 8-byte magic "CRYPTIN\0"
CMD_GET_PUBKEY 0x02 yes — 32 B K_identity pubkey
CMD_CHALLENGE 0x03 yes 32 B nonce 64 B Ed25519 signature, 2 reports
CMD_GET_SECRET 0x04 yes — ⚠️ deprecated — ERR_DEPRECATED on production firmware
(reserved) 0x05 — — not implemented
CMD_GET_ATTESTATION_PUBKEY 0x06 yes — 32 B K_attestation pubkey
(reserved) 0x07 — — unused
CMD_VERIFY_PIN 0x08 no 6 B ASCII PIN status only
CMD_GET_PROVISIONING 0x09 no — 32 B entropy + 6 B ASCII PIN
CMD_ACK_PROVISIONING 0x0A no — status only
CMD_SET_LICENSE_JWT 0x0B no JWT, chunked status per chunk
CMD_GET_LICENSE_STATUS 0x0C yes — 4 B expires_at (unix, big-endian)
CMD_ENCRYPT_BLOCK 0x0D yes + licence chunked plaintext ⚠️ ERR_DEPRECATED since beta-2026-10-13 — v1 files are read-only
CMD_DECRYPT_BLOCK 0x0E yes chunked ciphertext chunked plaintext
CMD_ENCRYPT_BLOCK_V2 0x0F yes + licence flags, then chunked plaintext salt + nonce, ciphertext, tag
CMD_DECRYPT_BLOCK_V2 0x10 yes flags + salt + nonce, chunked ciphertext, then the file’s tag + a host nonce chunked plaintext, then the host nonce echoed
CMD_REKEY_BEGIN 0x11 yes + the current PIN again 6 B ASCII current PIN 32 B new entropy + 6 B ASCII new PIN
CMD_REKEY_COMMIT 0x12 yes 6 B ASCII new PIN status only; one try

Anything else returns STATUS_UNKNOWN_CMD (0xFF) with the opcode echoed — but only on an unlocked session. The PIN gate runs first, so while the session is locked an unknown opcode gets ERR_PIN_REQUIRED (0xE1) like any gated command. Do not use 0xFF to probe which opcodes exist.

New identity on the same chip — not in any published firmware yet. Replaces the identity seed without reprogramming the chip; K_attestation does not change.

  • BEGIN takes the current PIN again, even on an unlocked session: a process that only rides an open session must not be able to throw the identity away. It is counted exactly like CMD_VERIFY_PIN (same counter, one per second, three tries).
  • The new seed is in BEGIN’s reply and nowhere else. It waits on the chip while the current identity stays active; no command reads it back. A lost reply means BEGIN again, which replaces it.
  • COMMIT carries the new PIN, one try. A wrong PIN drops the pending seed (ERR_REKEY_PIN_MISMATCH); nothing pending is ERR_REKEY_NONE.
  • On STATUS_OK the new identity is live: any block transfer is aborted, the attempt counter is reset and the session is locked — the next accepted PIN is the new one.
  • Power cuts: before COMMIT the old identity is intact and the pending seed is dropped at the next boot; inside COMMIT the switch is completed at the next boot.
  • Order for a client: decrypt what must stay readable first → unlock → rekey/start signed by the old identity → BEGIN → show words and PIN → COMMIT with the new PIN → CMD_VERIFY_PIN (new) → rekey/finish signed by the new identity, same attestation → CMD_SET_LICENSE_JWT with the returned token.
  • Asking the chip whether it can: on an unlocked session, BEGIN with an empty argument answers STATUS_BAD_LENGTH on firmware that has it and STATUS_UNKNOWN_CMD on firmware that does not. That is what the SDK’s SupportsOnChipRekey() does.

These are the format version 2 commands, and the only ones that write.

  • Standard AES-256-GCM, and the result does not depend on how the host cuts the payload into chunks. This is the whole point of version 2: a v2 file opens with any AES-GCM implementation, given file_secret.
  • The chip rebuilds the header itself and uses it as AAD — "CRIN", version 2, the flags the host sent, the chip’s own K_identity, the salt and the nonce. The host does not supply the header, and cannot make the chip authenticate a header it did not build.
  • HKDF label: cryptin-file-key-v2 (v1 used cryptin-file-key-v1; keys derived from the same secret and salt under the two labels are unrelated).

Framing: the same header as §6, a different numbering

Section titled “Framing: the same header as §6, a different numbering”

v2 reuses the eight-byte chunk header of §6 — hdr = [CMD][argLen][seq 3B][total 3B], seq and total big-endian — but not its numbering, and this is the detail that breaks a client ported from v1:

  • total counts the data chunks only;
  • the opening is seq 0 and carries no data (in §6, chunk 0 carries data and is counted);
  • the data chunks are seq 1..total;
  • the close is seq total+1 (in §6 it is seq total).

An empty file is therefore total = 0: an opening, then a close at seq 1.

Offsets below are counted as in §2 — byte 0 is the opcode, the report id 0x01 is not part of the frame. argLen is 6 + the data bytes that follow the header.

exchange seq OUT after the header, from byte 8 argLen IN
open, encrypt 0 [flags] 7 [cmd][OK][salt 16][nonce 12] — salt at bytes 2–17, nonce at 18–29
open, decrypt 0 [flags][salt 16][nonce 12] — byte 8, bytes 9–24, bytes 25–36 35 [cmd][OK]
data 1..total [≤55 bytes] — bytes 8–62 6 + n [cmd][OK][len][≤55 bytes] — len at byte 2, data from byte 3
close, encrypt total+1 — 6 [cmd][OK][tag 16] — bytes 2–17
close, decrypt total+1 [tag 16][host nonce 16] — bytes 8–23, bytes 24–39 38 [cmd][OK][host nonce 16] at bytes 2–17, or ERR_TAG_MISMATCH with no nonce

Note the asymmetry of the close, because it is the one most often got backwards: on encrypt the host sends nothing and the dongle answers the tag; on decrypt the host sends the tag it read from the file and a nonce of its own, and the dongle answers the nonce echoed — it never returns the tag.

What the firmware rejects, and with which status:

  • a flags byte with any bit other than bit 0 set → STATUS_BAD_LENGTH. Bit 0 means “zipped folder”;
  • an argLen other than the one in the row — 7 or 35 on the opening, 6 or 38 on the close → STATUS_BAD_LENGTH;
  • a chunk out of order, a v1 chunk inside a v2 transfer or the reverse, or a chunk carrying the opcode of the wrong direction (0x0F inside a decryption, 0x10 inside an encryption) → the transfer is abandoned with ERR_SEQ_ERROR.

The licence is checked on the opening of an encryption. A decryption has no licence check — consistent with §6: files already written must keep opening whatever the licence says.

Two rules a client must implement, not just read:

  • Check the echoed host nonce on the decrypt close and treat any difference as a failed tag. Without it, a second process on the same unlocked session can produce a reply that looks like the verified end of your decryption (L3 in the security advisory).
  • The plaintext arrives before the verdict. The tag is checked at the close, after the chip has already handed you the bytes. If the close does not answer STATUS_OK, discard everything you received — do not write it, do not show it.

Firmware older than beta-2026-10-13 answers STATUS_UNKNOWN_CMD to both, which is how a client detects it must fall back to reading v1 only.


Five opcodes are exempt: 0x01, 0x08, 0x09, 0x0A, 0x0B. Everything else answers ERR_PIN_REQUIRED (0xE1) until CMD_VERIFY_PIN has succeeded.

0x0B is exempt and must stay so: a licence token has to be able to reach a locked dongle, and it carries its own RS256 signature bound to that chip’s K_attestation, so an unauthenticated caller can deliver one but not forge one.

PIN length 6 ASCII digits
Attempts 3, then blocked
Rate at most one attempt per second — an earlier one answers ERR_PIN_TOO_SOON (0xE5), not counted
Auto-lock 10 minutes of inactivity

The PIN is derived from the seed. The user does not choose it and cannot change it. The attempt is counted and written to flash before the PIN is compared, so cutting power during an attempt does not give a free guess (since beta-2026-10-13; earlier firmware had that gap, see the security advisory). After three failures only a recovery with the 24 words restores access.

Two rules that catch every client eventually

Section titled “Two rules that catch every client eventually”

ERR_PIN_REQUIRED can arrive on a session you believe is open. The ten-minute auto-lock fires on the chip, not in your process. It is not an error to display — it is a request for the PIN. A cached “I am unlocked” boolean is exactly what let an auto-lock go unnoticed in our own app.

Only gated commands refresh the deadline. CMD_PING never does — it is answered before the gate, and letting it count would let any process hold a session open forever without ever proving it knows the PIN.

Verified on real hardware (July 2026). A test dongle with PIN 902579 blocked after three wrong attempts and stayed blocked when the correct PIN was entered afterwards. Recovery with the seed restored access, and K_attestation was unchanged by the recovery — as designed, since it is never derived from the seed.


CMD_CHALLENGE answers in two reports. Byte 1 carries a sequence number (0, 1) instead of a status, and each report carries 32 bytes of the 64-byte signature.

CMD_SET_LICENSE_JWT takes the token in chunks. An open transfer expires after 30 seconds of silence, so a prefix abandoned half-way cannot be completed later by a different caller.

CMD_CHALLENGE signs whatever 32-byte nonce you supply. The dongle does not check that the nonce is fresh, and cannot: the only state it keeps across commands is the PIN unlock, and it remembers nothing about nonces. Generate the nonce from a cryptographically secure RNG, use it once, and never accept a signature over a nonce you did not just generate.

Since beta-2026-10-13 the Ed25519 signature nonce is hedged (randomised on top of the deterministic one), and the dongle verifies every signature before sending it — a signature that fails its own check is not sent and the command answers ERR_SIGN_FAULT (0xF9). Both changes come from F1 in the security advisory.


This is the part the previous version of this page did not have.

The cipher runs on the dongle. The host streams plaintext in and gets ciphertext back; the file key is derived on-chip and never crosses the wire. That is what makes an alternative client unable to encrypt without passing the licence check.

Encryption is gated on PIN and licence. Decryption on the PIN only — files already written must open for as long as the dongle exists, whatever the licence says.

Every chunk, opening or not, starts with the same eight bytes:

hdr = [CMD][argLen][seq 3B][total 3B]

seq and total are 3 bytes each, big-endian. argLen is the transport’s own length byte from §2 — the block commands do not add a second one. The payload length is argLen − 6.

encrypt, chunk 0: OUT [hdr][<=27 plaintext]
IN [CMD][STATUS][16 salt][12 nonce][ciphertext]
decrypt, chunk 0: OUT [hdr][16 salt][12 nonce][<=27 ciphertext]
IN [CMD][STATUS][len][plaintext]
chunks 1..total-1: OUT [hdr][<=55 bytes]
IN [CMD][STATUS][len][<=55 bytes]
close, seq = total: OUT [hdr] (encrypt)
IN [CMD][STATUS][16-byte tag]
OUT [hdr][16-byte tag] (decrypt)
IN [CMD][STATUS]

total counts the data chunks including chunk 0, so the close is the sequence number after the last of them.

  • The salt and the nonce are minted by the dongle, not by you, and come back in the opening reply for you to copy into the .crin header. A nonce repeated under one file key destroys GCM’s guarantees, and the host is precisely the party this design does not trust with that choice.
  • seq == 0 reopens a transfer unconditionally, whatever state the previous one was in. Abandoning a transfer half-way therefore needs no cleanup — which is what makes a Cancel button safe.
  • Any chunk out of order abandons the transfer and answers ERR_SEQ_ERROR (0xF8). It does not resynchronise. Start again from seq = 0.
  • The 5 000 000-byte ceiling per transfer is the Windows client’s, not the protocol’s. Personal v1 enforces it (MaxEncryptBytes = 5,000,000) for UX reasons; the firmware and the .crin format carry transfers of up to ~900 MB, which is what the 3-byte counters address. A developer driving the SDK directly is not subject to it — what runs out first is time. At the measured ~27.8 KB/s, 5 MB is already about three minutes in each direction: budget for it in your UI, and report progress.

Code Name Meaning
0x00 STATUS_OK success
0x01 STATUS_NOT_READY key material not loaded yet
0x02 STATUS_BAD_LENGTH argument length wrong — not counted as a PIN attempt
0xE1 ERR_PIN_REQUIRED session locked — send CMD_VERIFY_PIN first
0xE2 ERR_PIN_WRONG wrong PIN, attempt counted
0xE3 ERR_PIN_BLOCKED attempts exhausted; only a seed recovery unblocks
0xE4 ERR_ALREADY_ACKNOWLEDGED provisioning already completed; terminal
0xE5 ERR_PIN_TOO_SOON less than a second since the last attempt; not counted
0xE6 ERR_REKEY_NONE CMD_REKEY_COMMIT with no pending seed (never begun, dropped by a reboot or by a wrong PIN)
0xE7 ERR_REKEY_PIN_MISMATCH CMD_REKEY_COMMIT with a PIN that is not the pending seed’s; the pending seed is dropped
0xF1 ERR_INVALID_JWT licence signature did not verify
0xF2 ERR_JWT_EXPIRED token already expired on arrival
0xF3 ERR_NO_LICENSE no token stored
0xF4 ERR_LICENSE_EXPIRED stored token is past expires_at — not returned by shipped firmware, see the note below the table
0xF5 ERR_INVALID_SIGNATURE stored token no longer verifies
0xF6 ERR_TAG_MISMATCH GCM tag failed on decrypt
0xF7 ERR_DEPRECATED CMD_GET_SECRET on firmware that no longer exports it
0xF8 ERR_SEQ_ERROR chunk out of order; transfer abandoned
0xF9 ERR_SIGN_FAULT CMD_CHALLENGE computed a signature that failed its own verification and did not send it; retrying is fine
0xFF STATUS_UNKNOWN_CMD unrecognised opcode

ERR_LICENSE_EXPIRED (0xF4) is specified but is not returned by shipped firmware: the dongle has no RTC and nothing sets its clock, so the comparison against expires_at is skipped. Expiry is enforced by the licence server at activation and transfer, not on the chip.


The dongle has two USB sockets on a development board and they are not interchangeable. The native OTG port is where the HID appears (VID_303A&PID_8123). The UART bridge (VID_1A86, CH343) is a serial port and the HID will never appear there.

A dongle can come up without its application. If it enumerates as VID_303A&PID_1001 it is running the ROM’s USB-Serial/JTAG instead — no HID at all, and to your client it looks exactly like a dongle that is not plugged in. Unplugging and replugging usually clears it. Anything that grabs the device during the moment it hands USB over from ROM to application can cause it — virtual machine USB passthrough is the common culprit, and if your hypervisor offers you a “JTAG/serial debug unit”, that is this.

Discard replies whose echoed opcode is not yours. A reply left unread by a previous exchange stays queued and will be handed to your next read.


When you power on a freshly-flashed Crypt-in dongle for the first time, the following happens entirely on the chip — no network, no server, no lake8.dev:

  1. Hardware RNG generates 256 bits of entropy.
  2. A BIP-39 mnemonic (24 words) is derived from that entropy. It is shown once on serial (UART0, 115200 baud), never stored in plaintext, and never transmitted anywhere.
  3. K_root is derived from the entropy.
  4. K_identity (Ed25519 keypair) is derived from K_root.
  5. K_attestation is generated from separate hardware entropy — chip-unique, not derivable from the seed.
  6. The 6-digit PIN is derived from K_root via HKDF-SHA256 (info="cryptin-pin-v1") and shown once, next to the mnemonic. It is never chosen by the user and never transmitted.
  7. All keys are persisted in NVS.
  8. The dongle is ready. No activation is required for key generation.

lake8.dev is not contacted during this process. lake8.dev has never seen your seed.

This holds for pre-assembled Kits too. A Kit is flashed and functionally tested before shipping, so it does boot once on our bench — and the factory test ends by erasing NVS. Everything goes with it — the key namespaces cryptin and cryptin_attest, and the PIN attempt counter in cryptin_pin — so the test keys are destroyed rather than shipped, and the dongle reaches you with a fresh attempt budget. The dongle that reaches you has firmware and no keys: the boot that generates the keys you will actually use is the first one you perform yourself, and its seed is shown on your serial console, never on ours.

The licence server is contacted only later, during activation (POST /api/activate) — and even then only two public keys are transmitted, the K_identity public key and the K_attestation public key. The seed and every private key never leave the chip.


Crypt-in key hierarchyK_root is generated on-chip from hardware entropy and never leaves it. It derives K_identity, recoverable from the BIP-39 seed, and K_attestation, which is unique to the physical chip and not recoverable. K_identity derives the per-session key K_session, which in turn derives the per-file AES-256-GCM key K_file.K_rootnever leaves chipK_identityrecoverable from seedK_attestationchip-unique,not recoverableK_sessionephemeral, per sessionK_fileper-file AES-256-GCM key

The diagram shows the target architecture. Implemented today: K_root, K_identity, K_attestation and the per-file key. The K_session layer is on the roadmap — see the note below.

  • K_root — Root secret. Never leaves the chip.
  • K_identity — Ed25519 keypair derived from K_root. Recoverable from BIP-39 seed.
  • K_attestation — Hardware entropy keypair. Not recoverable. Unique per physical chip.
  • file_secret — Fixed 32-byte secret, domain-separated from the Ed25519 key material. HKDF input only; never encrypts anything. Since firmware 2026-08-02 it is not exported on production firmware: CMD_GET_SECRET (0x04) answers ERR_DEPRECATED, and the derivation that used to happen on the host now happens on the chip. On the developer beta track (-dev) the command still responds — see the beta section below.
  • K_session — Roadmap, not implemented. Planned as an ephemeral per-session key exported through the reserved command 0x05. Current firmware keeps no session state.
  • K_file — AES-256-GCM key derived from file_secret + per-file salt, on the dongle, and never handed to the host.
  • PIN — 6 digits derived from K_root via HKDF-SHA256 (info="cryptin-pin-v1"). Not a key: an authentication factor, deterministic from the seed. See the PIN gate.

K_root and all derived keys live in the ESP32-S3 NVS (Non-Volatile Storage).

Security status, updated August 2026. Flash Encryption and Secure Boot v2 are active on production dongles (Kit Standard, Kit Pro — April 2027), where the eFuses are burned at first boot.

On such a dongle NVS contents are encrypted under a device-unique key burned into eFuse, so reading the flash physically yields ciphertext. Verified on production hardware: K_root is not extractable from an SPI dump, read-flash is refused because the chip is in Secure Download Mode, and JTAG is permanently disabled.

On developer beta units none of that applies. Since 24 August 2026 they run the -dev firmware track with the eFuses not burned: NVS is not encrypted, an SPI dump of the flash is readable, and CMD_GET_SECRET (0x04) responds. K_root is extractable from a beta dongle by anyone holding it. This is the price of a reversible board, and it is stated plainly on the beta page.

Anti-rollback is not implemented yet — declared openly. It requires OTA support that the current firmware does not have, and is planned before the public launch — but only for chips burned after that point. The check lives in the bootloader, and a chip with Secure Boot in release mode cannot have its bootloader replaced: production dongles burned today can never gain it. Developer beta units are the exception — their eFuses are not burned, so the bootloader is replaceable and anti-rollback can reach them with a future firmware. See update security for what stands in its place, and the security model for what that means in practice.


This page is the normative specification for the HID protocol. The host-side implementation is in the published repository (src/CryptinSDK/), but CRYPTIN_PROTOCOL.md is an internal document and is not part of it, so write your client against what is written here. If you find an inconsistency, this page is the bug — report it to security@lake8.dev if the difference is security-relevant, to info@lake8.dev otherwise. The repository takes no issues during the beta.

Alcuni contenuti sono stati redatti con il supporto di strumenti di intelligenza artificiale generativa e revisionati dall'autore. Le immagini hardware hanno scopo puramente illustrativo.

Some content was drafted with the support of generative AI tools and reviewed by the author. Hardware images are purely illustrative.

Einige Inhalte wurden mit Unterstützung generativer KI-Werkzeuge verfasst und vom Autor überprüft. Hardware-Abbildungen dienen ausschließlich illustrativen Zwecken.

Algunos contenidos han sido redactados con el apoyo de herramientas de IA generativa y revisados por el autor. Las imágenes de hardware tienen carácter meramente ilustrativo.

In caso di conflitto tra versioni linguistiche, prevale il testo in lingua italiana.