Skip to content
🔧 Developer beta launches 30 September 2026 — 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 ⚠️ deprecatedERR_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 chunked ciphertext + tag
CMD_DECRYPT_BLOCK 0x0E yes chunked ciphertext chunked plaintext

Anything else returns STATUS_UNKNOWN_CMD (0xFF) with the opcode echoed.


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
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 counter lives in flash, not RAM: power-cycling between guesses does not reset it. 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.


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
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
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
0xFF STATUS_UNKNOWN_CMD unrecognised opcode

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_sessionRoadmap, 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.


The normative source for this page is CRYPTIN_PROTOCOL.md in the SDK repository. Where this page and that document disagree, the document is right and this page is the bug.

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.