Status: v0.1 draft. Normative for the browser extension and the KeyOS app.
Both sides implement this with types from the protocol crate.
- Transport. WebUSB in production; WebSocket for the macOS simulator. Transports carry UTF-8 JSON messages.
- Framing. WebUSB uses newline-delimited JSON chunked across 64-byte interrupt transfers. WebSocket uses one text frame per JSON message.
- Messages. JSON requests and responses, described below.
- Vendor-class USB interface advertised with class/subclass/protocol
0xFF/0xFF/0xFF. - Two interrupt endpoints: one OUT (host → device), one IN (device → host), both max packet length 64 bytes.
- WebUSB Platform Capability vendor code:
0x1E. - Dedicated production VID/PID: TBD. Dev builds pair by WebUSB device picker and interface-class matching.
ws://localhost:9876by default.- Text frames. One frame = one JSON message.
- No newline framing layer is needed beyond the WebSocket text frame.
Each WebUSB message is a single JSON blob followed by \n. The host and device
split the byte stream into 64-byte interrupt transfers and reassemble until the
newline delimiter.
Receivers MUST:
- Ignore empty lines.
- Reject invalid UTF-8/JSON with
invalid_request. - Cap a single line at 16 KiB and drop the in-progress line on overflow.
Senders SHOULD:
- Emit exactly one trailing
\nafter each JSON message. - Keep one request in flight per device connection unless/until the protocol grows explicit multiplexing semantics.
The protocol::frame HID report framer remains as a host-testable utility for
older experiments, but it is not the production WebUSB wire format.
The earlier proof-of-concept used fixed 64-byte HID reports:
offset 0 1 2..=3 4..=63
field flags rsv len_be payload
flagsbit 7INIT(first report in a message), bit 0FINAL(last report in a message). Other bits reserved, must be 0.rsvreserved, must be 0.len_bebig-endianu16; number of valid payload bytes in this report, 0..=60.payloadthe JSON bytes; bytes beyondlenare ignored.
A single-report message sets both INIT and FINAL.
Legacy HID receivers MUST:
- Start assembly on
INIT, discarding any in-progress buffer. - Reject continuation reports that arrive without a prior
INIT. - Cap total payload at 16 KiB and error out on overflow.
Legacy HID senders SHOULD:
- Emit reports in order. HID guarantees in-order delivery, so no sequence number is needed on the wire.
Messages are UTF-8 JSON. The two envelope types are Request and
Response, with matching id strings for correlation.
{
"id": "<unique-string>",
"method": "<method-name>",
"params": { ... } // optional, shape depends on method
}The id is echoed verbatim in the response. Senders choose it (a
monotonic counter or short random string).
Either success:
{ "id": "<same-as-request>", "result": { ... } }or error:
{ "id": "<same-as-request>",
"error": { "code": <int>, "message": "<str>" } }| Code | Name | Meaning |
|---|---|---|
| 1 | invalid_request |
Bad JSON, unknown fields, wrong types. |
| 2 | unknown_method |
Method name not recognised. |
| 3 | unknown_key |
No stored identity matches the supplied uuid. |
| 4 | user_rejected |
User declined the on-device approval prompt. |
| 5 | timeout |
User did not respond within the device's timeout. |
| 6 | not_unlocked |
Device is locked; user must enter PIN. |
| 99 | internal |
Unexpected device-side failure. |
All method names are snake_case. The method surface is intentionally close to NIP-46 so the vocabulary carries over to a relay-proxied transport without breaking clients.
Liveness check. Returns { "pong": true }.
Returns the public metadata for every stored identity. Does not require the device to be unlocked beyond initial login — no ciphertext is touched.
Response result:
{
"keys": [
{ "uuid": "<hex16>", "label": "QnA", "pubkey": "<hex32>",
"created_at": 1714078911 }
]
}Picks a default identity for subsequent calls. No user prompt.
{ "method": "select_key", "params": { "uuid": "<hex16>" } }Response: { "selected": "<hex16>" }.
Returns the x-only pubkey of the currently-selected identity, or the one
referenced by uuid in params if provided.
Response: { "pubkey": "<hex32>" }.
Signs a NIP-01 event. Requires physical approval on the device unless the event matches an owner-configured auto-sign policy stored locally on the device.
{
"method": "sign_event",
"params": {
"uuid": "<hex16>", // optional
"origin": "https://nostrudel.ninja", // optional, surfaced in prompt
"event": {
"pubkey": "<hex32>",
"created_at": 1714078911,
"kind": 1,
"tags": [],
"content": "gm"
}
}
}Response: the complete signed event including id and sig.
Auto-sign is not exposed as a wire method. Browser clients and the extension cannot create, edit, or delete policies.
Before showing the approval screen, the device checks local policy for
sign_event only. A policy matches exactly by live key UUID, normalized HTTP(S)
origin, and event kind. Matched policies must be enabled, unexpired, and under
their hourly usage cap. If any check fails, the request returns to the normal
manual approval path.
Policy storage is authenticated with a MAC key derived from the KeyOS app seed. If the policy file fails verification, auto-sign is disabled for that session and signing remains manual.
Legacy DM cryptography. plaintext/ciphertext are strings in NIP-04
format (base64 + ?iv= suffix). Both directions require physical approval on
the device before ciphertext or plaintext is returned to the browser.
NIP-44 v2 cryptography used for NIP-17 DMs and NIP-46 transport.
Both encrypt and decrypt methods require physical approval. A future explicit per-origin DM-reading policy may relax repeated decrypt prompts, but the public default is fail-closed.
- The nsec never leaves the device.
get_public_keyandlist_keysonly surface public material. - Every
nip*_encryptandnip*_decryptrequest shows the user:- the origin, if supplied,
- a content preview, truncated to the display width,
- the key label and peer pubkey.
- Every
sign_eventrequest either shows the same approval details (origin, event kind, content preview, tag count, and npub) or matches an explicit on-device auto-sign rule. - Auto-sign rules are intentionally narrow: no wildcards, no browser-created grants, exact origin and kind matching, expiry, hourly cap, and audit log.
- The USB cable is the trust boundary. The transport layer has no encryption; an attacker with physical access to both the device and a malicious host can observe signing-request contents (though not the nsec). This matches the trust model of the existing FIDO app.