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 | ⚠️ 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.
REKEY_BEGIN / REKEY_COMMIT
Section titled “REKEY_BEGIN / REKEY_COMMIT”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 isERR_REKEY_NONE. - On
STATUS_OKthe 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/startsigned by the old identity → BEGIN → show words and PIN → COMMIT with the new PIN →CMD_VERIFY_PIN(new) →rekey/finishsigned by the new identity, same attestation →CMD_SET_LICENSE_JWTwith the returned token. - Asking the chip whether it can: on an unlocked session, BEGIN with an
empty argument answers
STATUS_BAD_LENGTHon firmware that has it andSTATUS_UNKNOWN_CMDon firmware that does not. That is what the SDK’sSupportsOnChipRekey()does.
ENCRYPT_BLOCK_V2 / DECRYPT_BLOCK_V2
Section titled “ENCRYPT_BLOCK_V2 / DECRYPT_BLOCK_V2”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", version2, the flags the host sent, the chip’s ownK_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 usedcryptin-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:
totalcounts the data chunks only;- the opening is
seq 0and 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 isseq 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
flagsbyte with any bit other than bit 0 set →STATUS_BAD_LENGTH. Bit 0 means “zipped folder”; - an
argLenother 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 (
0x0Finside a decryption,0x10inside an encryption) → the transfer is abandoned withERR_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.
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 |
| 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.
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.
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.
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 |
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.
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.
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.
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.