USB-Slave Serial Protocol

The USB-slave interface is a request/response protocol carried over a CDC-ACM virtual serial port (Product ID 0x41E3). It lets a host read and write individual credentials, enumerate the store, look up web passwords by domain, and stream an encrypted backup. Most operations require the user to confirm on the device.

Transport and connection

Entering USB-slave mode

The serial interface is not always present. The user must select “Usb slave” from the on-device menu; the device then re-enumerates as a CDC-ACM virtual serial port. The device leaves the mode when the user presses the UP key, at which point it re-enumerates as the keyboard personality.

The practical consequence for a client is that the serial port node appears when the user enters “Usb slave” and disappears when they leave. A client must handle the port node appearing and disappearing and cannot force the device into this mode remotely.

Device display showing "Usb slave"

The device while in USB-slave mode with no command pending. As long as this screen is shown, the serial port exists on the host.

Host-side setup

Linux. The device enumerates as a standard CDC-ACM port, typically /dev/ttyACM0. Install a udev rule so that (1) ModemManager ignores the port and (2) a stable symlink is created. The ModemManager exclusion is important: otherwise ModemManager probes the new ACM port with AT commands and corrupts your first exchange. A minimal rule keyed off the USB strings:

ATTRS{manufacturer}=="SECLAVE", ATTRS{product}=="SECLAVE2", \
    ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_CANDIDATE}="0"
ACTION=="add", SUBSYSTEM=="tty", ATTRS{manufacturer}=="SECLAVE", \
    ATTRS{product}=="SECLAVE2", SYMLINK+="seclave", GROUP="seclave"

Install it into /etc/udev/rules.d/ and prefer opening the stable /dev/seclave symlink over a raw ttyACMx number.

Windows. The CDC device binds to the in-box usbser.sys driver via an INF that matches USB\VID_20A0&PID_41E3 in the Ports class. After install the device appears as a COMx port.

macOS. No driver is required; macOS has native in-box USB-CDC (ACM) support. The port appears as /dev/cu.usbmodem* (and /dev/tty.usbmodem*).

Serial line settings

CDC-ACM is a virtual UART, so line settings are cosmetic. The device accepts and stores SET_LINE_CODING / GET_LINE_CODING but does not act on them; the reported default coding is 460800 8N1. Baud rate does not matter - any value works, and a client need not set one. A client should:

  • open the node O_RDWR | O_NOCTTY;

  • put the tty into raw mode - disable ICANON, ECHO, ISIG, IXON, OPOST, ICRNL; set CS8, VMIN=1, VTIME=0;

  • not bother configuring baud.

The device sends on 64-byte USB bulk packets, so a single host read() may return only part of a response. See Client implementation checklist.

Framing

There are two framing layers. Do not confuse them.

Outer message framing (host to device only)

Every command the host sends is prefixed with a 2-byte little-endian length giving the number of payload bytes that follow:

[ len_lo ][ len_hi ][ payload (len bytes) ]

where len = len_lo | (len_hi << 8).

Warning

Valid range is 1 <= len <= 228. A len of 0 or greater than 228 makes the device drop out of USB-slave mode entirely and return to the keyboard personality - the port simply disappears. Never send an over-long frame. The largest legitimate request (a fully populated PUT_ENTRY) is well under this limit, so treat an over-long frame as a client bug.

Responses are NOT outer-framed. The device writes the response bytes directly to the serial pipe with no length prefix and no trailer. The client must parse the response using the inner framing (below) to know where it ends. There is no delimiter, checksum, or terminator.

Inner payload framing (both directions)

The payload, and every response, is a sequence of integers and length-prefixed fields, using one variable-length encoding for both.

Integer encoding.

  • value < 254 -> one byte: [ value ]

  • otherwise -> escape: [ 0xFF ][ nbytes ][ value little-endian, nbytes long ] where nbytes is 1 (used for the special case 255) or 2 (values up to 65535). For example 255 encodes as FF 01 FF; 0x6543 encodes as FF 02 43 65.

Integer decoding.

  • first byte != 0xFF -> that byte is the value.

  • first byte == 0xFF -> the next byte is nbytes (1 or 2); read that many little-endian bytes as the value. Any other nbytes is a parse error.

Field encoding. A field is an integer length followed by exactly that many raw data bytes. Fields are not NUL-terminated on the wire; the length prefix is authoritative.

Command opcode. The first byte of the payload is the command opcode, a plain byte in the range 1-12. Because all opcodes are < 254 this coincides with the single-byte integer encoding.

Response shape

Every response begins with a status integer (see Status codes). If the status is anything other than USB_SLAVE_OK (0) - or USB_SLAVE_ERROR_MORE_LABELS (9), which is a success-with-more variant - there are no further bytes. On success, zero or more fields follow, depending on the command.

Command reference

Opcodes

Op

Name

Request payload (after opcode)

Success response fields

1

GET_GROUP

<label>

<group>

2

GET_USERNAME

<label>

<username>

3

GET_PASSWORD

<label>

<password>

4

GET_OPTIONAL

<label>

<optional>

5

GET_WWWFILL

<domain> <index:int>

<username> <password>

6

GET_LABELIDX

<index:int>

<label>

7

GET_WWWFILLIDX

<index:int>

<domain> <username>

8

PUT_WWWFILL

<domain> <username> <password>

(none – status only)

9

PUT_ENTRY

<label> <group> <username> <password> <optional>

(none – status only)

10

DEL_ENTRY

<label>

(none – status only)

11

DEL_WWWFILL

<domain> <username>

(none – status only)

12

GET_BACKUP

<index:int>

<blob: 224 bytes>

Status codes

Code

Name

Meaning

0

USB_SLAVE_OK

Success; fields (if any) follow.

1

USB_SLAVE_ERROR_ENTRY_NOT_FOUND

Label or web-password entry does not exist.

2

USB_SLAVE_ERROR_PARSE_ERROR

Malformed request. Session-fatal - see warning below.

3

USB_SLAVE_ERROR_ABORT

User rejected the operation on the device.

4

USB_SLAVE_ERROR_OUT_OF_INDEX

Index past end of list; used to terminate iteration.

5

USB_SLAVE_ERROR_LABEL_EXISTS

Label or web-password already present (put without replace).

6

USB_SLAVE_ERROR_NO_SPACE

Store is full (500 entries).

7

USB_SLAVE_ERROR_BAD_LABEL

Label or group has invalid characters or length.

8

USB_SLAVE_ERROR_BAD_DOMAIN

Domain has invalid characters or length.

9

USB_SLAVE_ERROR_MORE_LABELS

(GET_WWWFILL only) success, and more entries exist for this domain.

Warning

PARSE_ERROR is session-fatal and is not sent on the wire. When the device fails to parse a frame it does not send status 02 back - it switches straight back to the keyboard personality and the serial port disappears. A malformed frame therefore surfaces as the port going away, not as an error byte. If your port vanishes mid-session, suspect your framing. (Other pre-execution errors - NO_SPACE, BAD_LABEL, BAD_DOMAIN, ENTRY_NOT_FOUND - are returned as a normal status byte and the session continues.)

Byte offsets below are within the payload, after the opcode byte.

Get a field by label (op 1-4)

GET_GROUP, GET_USERNAME, GET_PASSWORD, GET_OPTIONAL.

  • Request: [op][ field: <len><label> ]. The label must match an existing entry’s label (looked up case-insensitively).

  • Response OK: [00][ field: <len><value> ].

  • Errors: ENTRY_NOT_FOUND (1) if the label is unknown; PARSE_ERROR (2) if the field is malformed or over-length.

GET_WWWFILL (op 5)

Web-password lookup by domain - the primary autofill read. See Web Passwords (wwwfill).

  • Request: [05][ field: <domain> ][ int: <index> ]. domain is the full site domain; index selects among multiple accounts for that domain (0-based).

  • Response OK: [status][ field: <username> ][ field: <password> ], where status is 00 (OK) if this was the last match, or 09 (MORE_LABELS) if further accounts exist for the same domain at higher indices. ``MORE_LABELS`` is a success code - the fields are present. Walk index = 0, 1, 2, ... while the status is MORE_LABELS and stop after you see OK.

  • Errors: ENTRY_NOT_FOUND (1) if no account matches.

GET_LABELIDX (op 6)

Enumerate all labels in alphabetical order.

  • Request: [06][ int: <index> ], 0-based.

  • Response OK: [00][ field: <label> ].

  • Terminator: OUT_OF_INDEX (4) once the index passes the last entry. Iterate from 0 until you get OUT_OF_INDEX.

Note

The response is exactly status followed by the label. There is no type or flag byte. Web-password entries are ordinary records in the reserved wwwfill group, so their auto-generated labels also appear in this enumeration; filter by group (or use GET_WWWFILLIDX) if you want to hide them. See Web Passwords (wwwfill).

GET_WWWFILLIDX (op 7)

Enumerate all web passwords.

  • Request: [07][ int: <index> ], 0-based.

  • Response OK: [00][ field: <domain> ][ field: <username> ]. Note the order: the domain is sent first, then the username.

  • Terminator: OUT_OF_INDEX (4) past the last entry.

PUT_WWWFILL (op 8)

Add a web password.

  • Request: [08][ field: <domain> ][ field: <username> ][ field: <password> ].

  • Response: status only. OK (0) on success.

  • Errors: NO_SPACE (6) if the store holds 500 entries; BAD_DOMAIN (8) if the domain fails validation; LABEL_EXISTS (5) if the same (domain, username) pair already exists on a non-forcing put.

  • Field handling: domain (stored in optional, max 83), username (max 50), and password (max 50) are truncated to their maximum lengths rather than rejected.

PUT_ENTRY (op 9)

Add a regular entry.

  • Request: [09][ field: <label> ][ field: <group> ][ field: <username> ][ field: <password> ][ field: <optional> ].

  • Response: status only. OK (0) on success.

  • Errors: NO_SPACE (6); BAD_LABEL (7) if label or group fail validation; LABEL_EXISTS (5) if the label already exists on a non-forcing put.

  • All five fields are truncated to their maximum lengths.

DEL_ENTRY (op 10)

Delete a regular entry.

  • Request: [0A][ field: <label> ].

  • Response: status only - OK (0), or ENTRY_NOT_FOUND (1).

DEL_WWWFILL (op 11)

Delete a web password.

  • Request: [0B][ field: <domain> ][ field: <username> ]. Both are required to disambiguate multiple accounts per domain.

  • Response: status only - OK (0), or ENTRY_NOT_FOUND (1).

Note

Unlike the put handlers, this command rejects over-length domain or username input with PARSE_ERROR (2) rather than truncating it. Because PARSE_ERROR is session-fatal, enforce the field maximums (83 / 50) client-side before sending.

GET_BACKUP (op 12)

Stream an encrypted backup export.

  • Request: [0C][ int: <index> ].

  • Response OK: [00][ field: <224-byte encrypted blob> ]. Each item is exactly 224 bytes.

  • Semantics: index == 0 initializes the export and returns the first encrypted record slot; index 1..499 return the remaining record slots; index == 500 returns the encrypted backup header and comes last; index > 500 returns OUT_OF_INDEX (4). A full export is index = 0..500 (501 items), iterating until OUT_OF_INDEX.

  • The blob is opaque and encrypted. Only a Seclave holding the matching backup key can restore it, via the mass-storage restore path. A client just streams the 224-byte items to a file. This is the same archive the device writes as SECLAVE.BKP over mass storage; see Backup export.

Access control and user confirmation

The device has a global access mode for the USB-slave interface, selected in the on-device Admin -> Slave security menu. The default is Normal.

Mode

Value

Behavior

Allow all

1

Every command executes immediately, no confirmation. Fully unattended.

Ask all

2

Every command (including web passwords) requires on-device confirmation.

Normal (default)

3

GET_WWWFILL and PUT_WWWFILL execute immediately; everything else requires confirmation.

Which operations block for confirmation

A “confirmed” command shows a prompt on the device’s screen and waits for the user to act: a select press allows it, anything else rejects it. While it waits, the device sends nothing - the host’s read() blocks indefinitely.

Device display showing "Show password for / github-work"

The confirmation prompt for a GET_PASSWORD on the entry github-work. While this is on screen the host’s read() is blocked; a select press returns the field, anything else returns ABORT (3).

  • Allow all: nothing blocks.

  • Ask all: every command blocks.

  • Normal: GET_WWWFILL (op 5) and PUT_WWWFILL (op 8) do not block - this is the reduced-confirmation path that makes browser autofill usable. All other commands block for confirmation.

Confirm once, then stream (enumerations)

The three iterating commands - GET_LABELIDX (6), GET_WWWFILLIDX (7), GET_BACKUP (12) - are confirmed only on the first call. Once the user allows the first one, every subsequent call of the same type in the session executes immediately. So a full label listing is:

  1. send GET_LABELIDX 0 -> device prompts, read() blocks;

  2. user allows -> device returns entry 0;

  3. send GET_LABELIDX 1..N -> each returns immediately;

  4. receive OUT_OF_INDEX -> done.

How rejection and blocking surface on the wire

  • User allows: the device executes and returns the normal status and fields.

  • User rejects a confirmed command: the device sends ABORT (3) and continues the session. Treat 3 as “user declined”.

  • Blocking: there is no “pending” wire signal. Between sending a confirmable command and the user acting, the device is silent. A client must use an infinite (or very generous) read timeout for confirmable commands. A short timeout will spuriously fail every confirmed operation. Surface a “waiting for device confirmation” hint in your UI.

Replacing an existing entry

A put onto a label or (domain, username) that already exists is a device-driven two-prompt sequence. On the first execute the device detects the conflict and, instead of returning LABEL_EXISTS, shows a second prompt (“Replace label?” / “Replace wwwfill”):

Device display showing "Replace label? / github-work"

The second prompt of a PUT_ENTRY onto an existing label. Only a confirm here produces a wire response; a decline sends nothing at all.

  • User confirms the replace -> the device deletes the existing record and re-adds, then returns a single OK (0). On the wire the client sees one status byte for the whole logical put, just delayed by the extra prompt.

  • User declines the replace -> in the current firmware the device sends nothing at all and loops back to reading the next command. A client blocked on read() for the put’s status therefore waits indefinitely - there is no ABORT and no LABEL_EXISTS for this branch.

Warning

Recommended client strategy: do not rely on the device-side replace prompt. When you intend to overwrite, do not send a plain put and wait for the device to prompt. Instead, on a LABEL_EXISTS (or unconditionally before an intended overwrite) issue an explicit DEL_ENTRY / DEL_WWWFILL and then re-put. This avoids the silent-wait branch entirely and gives you a definite status for every call.

Worked byte-level examples

H->D = host to device, D->H = device to host. Spaces are added for readability only.

List - first label (GET_LABELIDX, index 0)

H->D:  02 00              outer length = 2
       06                 opcode 6 = GET_LABELIDX
       00                 index integer = 0

In Normal mode the device prompts and blocks; on user allow, for a store whose first label is gmail:

D->H:  00                 status = USB_SLAVE_OK
       05                 field length = 5
       67 6d 61 69 6c     "gmail"

The next call GET_LABELIDX 1 (02 00 06 01) returns immediately (the enumeration latch is set). After the last label, at index N:

H->D:  02 00 06 <N>
D->H:  04                 status = OUT_OF_INDEX  -> stop iterating

Get a field (GET_GROUP for label “mylabel”)

H->D:  09 00              outer length = 9
       01                 opcode 1 = GET_GROUP
       07                 field length = 7
       6d 79 6c 61 62 65 6c   "mylabel"

In Normal mode the device prompts and blocks; on allow, if the entry’s group is work:

D->H:  00                 status = USB_SLAVE_OK
       04                 field length = 4
       77 6f 72 6b        "work"

If the label does not exist:

D->H:  01                 status = ENTRY_NOT_FOUND   (no field follows)

If the user rejects the prompt:

D->H:  03                 status = ABORT

Add an entry (PUT_ENTRY)

H->D:  28 00              outer length = 0x28 = 40
       09                 opcode 9 = PUT_ENTRY
       05 6c 61 62 65 6c              label    = "label"
       05 67 72 6f 75 70              group    = "group"
       08 75 73 65 72 6e 61 6d 65     username = "username"
       08 70 61 73 73 77 6f 72 64     password = "password"
       08 6f 70 74 69 6f 6e 61 6c     optional = "optional"
D->H:  00                 status = OK   (after "Add entry" confirmation)

Add a web password (PUT_WWWFILL)

H->D:  26 00              outer length = 0x26 = 38
       08                 opcode 8 = PUT_WWWFILL
       12 77 77 77 2e 62 69 67 2e 64 6f 6d 61 69 6e 2e 63 6f 6d
                          domain "www.big.domain.com" (0x12 = 18 bytes)
       08 75 73 65 72 6e 61 6d 65     username = "username"
       08 70 61 73 73 77 6f 72 64     password = "password"
D->H:  00                 status = OK   (Normal mode: no confirmation)

Client implementation checklist

  1. Infinite read timeout for confirmable commands. They block with no wire signal until the user acts. Short timeouts break everything.

  2. Responses are unframed. There is no length prefix or terminator on responses. Decode incrementally and know each command’s field count.

  3. Partial reads / packetization. The device sends in 64-byte USB packets, so a single read() may return a partial response. Never assume one read() equals one response - accumulate and re-attempt the parse as more bytes arrive.

  4. Outer length must be 1..228. A length of 0 or over 228 silently drops the device out of USB-slave mode.

  5. PARSE_ERROR equals disconnect. A malformed frame does not return 02; it kicks the device back to keyboard mode and the port disappears.

  6. Field truncation vs. rejection is inconsistent. Put handlers silently truncate over-long fields; DEL_WWWFILL rejects over-length input with a session-fatal PARSE_ERROR. Enforce the field maximums client-side.

  7. Character-set limits on label / group / domain. Only [A-Za-z0-9._-] plus the fixed Latin-1 accent set are legal; anything else yields BAD_LABEL / BAD_DOMAIN. Normalize web domains to a bare host.

  8. Case-insensitive labels and uniqueness. myLabel and mylabel collide. Handle replace by delete-then-put (see Replacing an existing entry).

  9. Web-password entries appear in ``GET_LABELIDX``. Filter on group wwwfill if you present a “regular entries” list.

  10. ``MORE_LABELS`` (9) is success, not error. For GET_WWWFILL, keep reading higher indices while the status is 9; stop at 0.

  11. ``OUT_OF_INDEX`` (4) is the loop terminator for GET_LABELIDX, GET_WWWFILLIDX and GET_BACKUP - not a hard error.

  12. Port lifecycle. The interface exists only while the user is in “Usb slave” mode. Detect the port appearing and disappearing; do not assume a persistent device node.

  13. ModemManager on Linux. Install the udev exclusion or ModemManager will probe the new ACM port with AT commands and corrupt your first exchange.