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.
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; setCS8,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 ]wherenbytesis1(used for the special case 255) or2(values up to 65535). For example255encodes asFF 01 FF;0x6543encodes asFF 02 43 65.
Integer decoding.
first byte
!= 0xFF-> that byte is the value.first byte
== 0xFF-> the next byte isnbytes(1or2); read that many little-endian bytes as the value. Any othernbytesis 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¶
Op |
Name |
Request payload (after opcode) |
Success response fields |
|---|---|---|---|
1 |
|
|
|
2 |
|
|
|
3 |
|
|
|
4 |
|
|
|
5 |
|
|
|
6 |
|
|
|
7 |
|
|
|
8 |
|
|
(none – status only) |
9 |
|
|
(none – status only) |
10 |
|
|
(none – status only) |
11 |
|
|
(none – status only) |
12 |
|
|
|
Code |
Name |
Meaning |
|---|---|---|
0 |
|
Success; fields (if any) follow. |
1 |
|
Label or web-password entry does not exist. |
2 |
|
Malformed request. Session-fatal - see warning below. |
3 |
|
User rejected the operation on the device. |
4 |
|
Index past end of list; used to terminate iteration. |
5 |
|
Label or web-password already present (put without replace). |
6 |
|
Store is full (500 entries). |
7 |
|
Label or group has invalid characters or length. |
8 |
|
Domain has invalid characters or length. |
9 |
|
( |
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> ].domainis the full site domain;indexselects among multiple accounts for that domain (0-based).Response OK:
[status][ field: <username> ][ field: <password> ], wherestatusis00(OK) if this was the last match, or09(MORE_LABELS) if further accounts exist for the same domain at higher indices. ``MORE_LABELS`` is a success code - the fields are present. Walkindex = 0, 1, 2, ...while the status isMORE_LABELSand stop after you seeOK.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 getOUT_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), orENTRY_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), orENTRY_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 == 0initializes the export and returns the first encrypted record slot;index1..499 return the remaining record slots;index == 500returns the encrypted backup header and comes last;index > 500returnsOUT_OF_INDEX(4). A full export isindex = 0..500(501 items), iterating untilOUT_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.BKPover 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 |
|
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.
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) andPUT_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:
send
GET_LABELIDX 0-> device prompts,read()blocks;user allows -> device returns entry 0;
send
GET_LABELIDX 1..N-> each returns immediately;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. Treat3as “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”):
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 noABORTand noLABEL_EXISTSfor 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¶
Infinite read timeout for confirmable commands. They block with no wire signal until the user acts. Short timeouts break everything.
Responses are unframed. There is no length prefix or terminator on responses. Decode incrementally and know each command’s field count.
Partial reads / packetization. The device sends in 64-byte USB packets, so a single
read()may return a partial response. Never assume oneread()equals one response - accumulate and re-attempt the parse as more bytes arrive.Outer length must be 1..228. A length of 0 or over 228 silently drops the device out of USB-slave mode.
PARSE_ERROR equals disconnect. A malformed frame does not return
02; it kicks the device back to keyboard mode and the port disappears.Field truncation vs. rejection is inconsistent. Put handlers silently truncate over-long fields;
DEL_WWWFILLrejects over-length input with a session-fatalPARSE_ERROR. Enforce the field maximums client-side.Character-set limits on label / group / domain. Only
[A-Za-z0-9._-]plus the fixed Latin-1 accent set are legal; anything else yieldsBAD_LABEL/BAD_DOMAIN. Normalize web domains to a bare host.Case-insensitive labels and uniqueness.
myLabelandmylabelcollide. Handle replace by delete-then-put (see Replacing an existing entry).Web-password entries appear in ``GET_LABELIDX``. Filter on group
wwwfillif you present a “regular entries” list.``MORE_LABELS`` (9) is success, not error. For
GET_WWWFILL, keep reading higher indices while the status is 9; stop at 0.``OUT_OF_INDEX`` (4) is the loop terminator for
GET_LABELIDX,GET_WWWFILLIDXandGET_BACKUP- not a hard error.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.
ModemManager on Linux. Install the udev exclusion or ModemManager will probe the new ACM port with AT commands and corrupt your first exchange.