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.
1. Transport
Section titled “1. Transport”| VID / PID | 0x303A / 0x8123 |
| Report size | 64 bytes, both directions |
REPORT_ID |
1 — mandatory, see below |
| Usage page | vendor-defined (0xFF00) |
REPORT_ID = 1 is not cosmetic
Section titled “REPORT_ID = 1 is not cosmetic”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.
2. Frame layout
Section titled “2. Frame layout”Every command and every response has the same shape.
OUT (host → dongle)
byte 0 command opcodebyte 1 length of the argument that follows (only for commands that take one)byte 2.. argument, zero-padded to 63 bytesIN (dongle → host)
byte 0 the command opcode, echoed backbyte 1 status code — or a sequence number, for CMD_CHALLENGE onlybyte 2.. response payload, zero-padded to 63 bytesByte 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.
3. Command table
Section titled “3. Command table”| 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 | chunked ciphertext + tag |
CMD_DECRYPT_BLOCK |
0x0E |
yes | chunked ciphertext | chunked plaintext |
Anything else returns STATUS_UNKNOWN_CMD (0xFF) with the opcode echoed.
4. The PIN gate
Section titled “4. The PIN gate”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.
5. Fragmented commands
Section titled “5. Fragmented commands”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.
Challenge freshness is the host’s job
Section titled “Challenge freshness is the host’s job”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.
6. Encryption on the chip — 0x0D / 0x0E
Section titled “6. Encryption on the chip — 0x0D / 0x0E”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.
Chunk framing
Section titled “Chunk framing”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.
Things you cannot guess from the framing
Section titled “Things you cannot guess from the framing”- 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
.crinheader. 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 == 0reopens 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 fromseq = 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.crinformat 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.
7. Status codes
Section titled “7. Status codes”| 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 |
8. Implementation notes
Section titled “8. Implementation notes”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.
9. First Boot Key Generation
Section titled “9. First Boot Key Generation”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:
- Hardware RNG generates 256 bits of entropy.
- 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.
- K_root is derived from the entropy.
- K_identity (Ed25519 keypair) is derived from K_root.
- K_attestation is generated from separate hardware entropy — chip-unique, not derivable from the seed.
- 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. - All keys are persisted in NVS.
- 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.
10. Key Hierarchy
Section titled “10. Key Hierarchy”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) answersERR_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.
NVS storage security
Section titled “NVS storage security”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.
Terms of ServicePrivacy PolicySecurity contact: security@lake8.devVulnerability Disclosure Policy
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.