This is the normative wire specification for askd, published so an independent implementation can be written without reading the source. It is byte-exact: every field, offset, byte order and signature input is pinned here, and the known-answer test vectors let you validate a parser byte-for-byte.
This specification and its test vectors are Apache-2.0: permissive on purpose, with a patent grant, so an independent implementation faces no barrier and owes no reciprocity. The reference implementation is a separate artefact, not public yet, released under EUPL-1.2 on delivery. Until then this document and the vectors are the normative reference.
Why this document exists at all. A protocol that only one codebase understands lasts exactly as long as that codebase. Publishing the wire format byte-exact, with vectors that let a second implementation prove itself correct, is what makes askd survivable independently of the people who wrote it: if this project stopped, an existing deployment would keep running and a competent implementer could rebuild a client from this page alone. That is the intent, and it is why the specification is published before the source rather than after.
Version: draft-01 (2026-07-05). Status: descriptive of the reference implementation at this tag. This document is byte-exact and intended to be sufficient to write an interoperable implementation without reading the source. Test vectors live in docs/test-vectors/ (generated by tools/).
Conventions: sizes in bytes; u16/u32/u64/i64 are unsigned/signed integers of that width. Byte order is stated per field: the framing layers use little-endian for ts_ms, seq, len, and big-endian only for the v1 response port. Fixed-size credential/audit structures are canonical host-native byte images (they are signed/hashed as a whole, never reordered).
Cryptographic primitives (all from the Zig standard library, no third-party code):
EcdsaP256Sha256), alternate CA credential signature (hardware CAs).crypto_box_seal, anonymous-sender), sealed audit-record encryption.The relay's TCP port answers nothing until it receives a valid first packet. The first byte selects the version: 0x01 (v1) -> read 89 bytes total; 0x03 (v3) -> read 505 bytes; anything else -> drop. Freshness window TIMESTAMP_TOL_MS = +/-30 000 ms (both edges inclusive). A 4096-entry nonce cache rejects replays within the window and is fail-closed (a full cache rejects rather than evicts).
| off | size | field | order |
|---|---|---|---|
| 0 | 1 | version = 0x01 | n/a |
| 1 | 16 | nonce | raw |
| 17 | 8 | ts_ms (i64) | little-endian |
| 25 | 64 | sig (Ed25519) | raw |
sig = Ed25519_sign(machine_key, bytes[0..25]) (version‖nonce‖ts). Verified against the machine's Ed25519 public key, which the relay must already trust (--pubkey). A v1 session is unrestricted (all capabilities). Signed length SIGNED_LEN = 25.
| off | size | field | order |
|---|---|---|---|
| 0 | 1 | version = 0x01 | n/a |
| 1 | 2 | port (u16) | big-endian |
| 3 | 16 | token (reserved, zero) | raw |
| off | size | field | order |
|---|---|---|---|
| 0 | 1 | version = 0x03 | n/a |
| 1 | 16 | nonce | raw |
| 17 | 8 | ts_ms (i64) | little-endian |
| 25 | 416 | cred (sec. 2, the 416-byte Cred image) | canonical |
| 441 | 64 | sig (Ed25519, machine key) | raw |
Two independent checks, both required: (1) sig = Ed25519_sign(machine_key, bytes[0..441]) verified against cred.pubkey, the key embedded inside the CA-signed credential, proving the knocker holds the key the credential vouches for; (2) cred is CA-signed and within its own validity window (sec. 2). The relay needs only the CA public key (--cred-ca-pub), not per-machine keys. The session's capabilities are cred.caps. Signed prefix V3_SIGNED_LEN = 441.
Cred), 416 bytesA flat, CA-signed structure binding {identity, machine public key, capabilities, validity}. Every byte except the trailing signature is signed -> no malleable region.
| off | size | field | notes |
|---|---|---|---|
| 0 | 1 | version = 0x03 | signed |
| 1 | 1 | ca_sig_alg | 0 = Ed25519, 1 = ECDSA-P256 |
| 2 | 6 | _pad0 | |
| 8 | 8 | valid_from_ms (i64) | |
| 16 | 8 | valid_to_ms (i64) | |
| 24 | 4 | caps (u32) | capability bitmask (sec. 2.1) |
| 28 | 1 | group_count (u8) | |
| 29 | 1 | target_count (u8) | |
| 30 | 2 | _pad1 | |
| 32 | 32 | identity | NUL-padded hostname/CN |
| 64 | 32 | pubkey | subject Ed25519 public key |
| 96 | 128 | groups[8][16] | NUL-padded labels |
| 224 | 128 | targets[8][16] | NUL-padded labels |
| 352 | 64 | ca_sig | CA signature over bytes [0..352] |
[0..352] (everything before ca_sig). ca_sig_alg selects Ed25519 (default) or ECDSA-P256 (hardware CA); both fit 64 bytes. Mismatch with the CA key type -> reject.now < valid_from_ms or now > valid_to_ms.SHA-256 hex over the signed body [0..352] (excludes ca_sig, since P-256 signatures are malleable) -> 64 hex chars. This is the stable identity used throughout the audit.caps, u32)| bit | value | name | grants |
|---|---|---|---|
| 0 | 0x01 | CAP_EXEC | one-shot: run a command |
| 1 | 0x02 | CAP_PUT | one-shot: upload a file |
| 2 | 0x04 | CAP_GET | one-shot: download a file |
| 3 | 0x08 | CAP_CONNECT | be a client (ask connect) |
| 4 | 0x10 | CAP_REGISTER | be an agent (askd) |
| 5 | 0x20 | CAP_JUMP | dial a client-chosen destination |
The relay appends an encrypted, tamper-evident ledger it cannot read back: each event is sealed to an offline auditor's X25519 public key (anonymous-sender), so the relay accumulates ciphertext.
AuditEvent (112 bytes, the plaintext):
| off | size | field |
|---|---|---|
| 0 | 8 | received_ms (i64) |
| 8 | 1 | event_type (u8): e.g. 'K'=knock, 0x02=data-session-started |
| 9 | 7 | _pad |
| 16 | 64 | fp_hex: credential fingerprint (auditor join key) |
| 80 | 32 | identity: NUL-padded hostname (empty for v1) |
On-disk record (192 bytes):
| off | size | field |
|---|---|---|
| 0 | 32 | chain = Blake2b256(prev_chain ‖ ciphertext) |
| 32 | 160 | ciphertext = SealedBox_seal(AuditEvent[112]) (112 + 48 seal overhead) |
The chain is unkeyed Blake2b-256: tamper-evident (a passive editor is detected), not tamper-proof (an adversary holding the auditor public key, which every relay has, could forge a consistent chain). This limitation is intentional and documented; the sealed-box confidentiality is the primary property. Reading requires the offline auditor private key (askd-ca audit-open).
After a successful knock the connection carries \n-terminated text control lines and, once a session is up, binary frames dispatched by first byte (0xFF/0xFD/0xFE/0xFC = frame, else = line). The relay is a stateless forwarder: it holds no reliability ring; sequence numbers are origin-scoped end to end.
| first byte | name | layout |
|---|---|---|
0xFF | MUX_DATA | [0] ‖ sid[16] ‖ seq(u32 LE) ‖ len(u16 LE) ‖ data[len] (23-byte header) |
0xFD | MUX_ACK | [0] ‖ sid[16] ‖ seq(u32 LE) (21 bytes): received-through seq |
0xFE | MUX_CLOSE | [0] ‖ sid[16] (17 bytes) |
0xFC | MUX_REPLAY | [0] ‖ sid[16] (17 bytes), relay->agent: replay the sid's ring |
MAX_MUX_DATA = 16384.
Session establishment (client->relay, gated by capability):
REGISTER <name> [version]: be an agent under <name> (needs CAP_REGISTER).CONNECT <host> <session_id_hex> [ <dest-host> <dest-port>], reach <host>; the optional pair is a JUMP destination (needs CAP_JUMP). Needs CAP_CONNECT.RESUME_CLIENT <tunnel_id_hex(32)> <proof_hex(64)>: resume a client path (needs CAP_CONNECT).RESUME <tunnel_id_hex(32)> <proof_hex(64)>: resume an agent (needs CAP_REGISTER).Control channel (relay <-> agent):
PING -> PONG.SEAL <event_id_hex(32)> <seq(dec)> <hash_hex(64)> <sig_hex(128)> -> appends a LedgerEntry, replies SEAL_OK (sec. 5).ADVERT <sid_hex(32)> <ctid_hex(32)> <csecret_hex(64)>: restart recovery re-advertises a held client session.SESS <sid_hex(32)> <session_id_hex(32)> <ctid_hex(32)> <csecret_hex(64)> <host>: relay->agent: a client connected; session_id = client-minted cross-path group key, sid = this relay's per-path routing key, ctid/csecret = the client's resume identity, host = requested backend.Handshake replies: OK then TUNNEL <tunnel_id_hex(32)> <secret_hex(64)>; resume: RESUME_OK / RESUME_NO.
LedgerEntry, 192 bytes (what a SEAL writes)| off | size | field |
|---|---|---|
| 0 | 8 | received_ms (i64) |
| 8 | 64 | cert_fp_hex |
| 72 | 16 | session_id |
| 88 | 8 | chain_seq (u64) |
| 96 | 32 | chain_head_hash |
| 128 | 64 | endpoint_sig (agent Ed25519 signature, sec. 6) |
SEAL constructionEach agent keeps an unkeyed Blake2b-256 hash chain. advance(event_id, ts_ms, event_type):
new_head = Blake2b256( head(32) ‖ event_id(16) ‖ seq(u64 LE) ‖ ts_ms(i64 LE) ‖ event_type(1) )
seq += 1
It then signs to_sign(56) = new_head(32) ‖ seq(u64 LE, 8) ‖ event_id(16) with the agent's Ed25519 key and sends SEAL <event_id_hex> <seq> <new_head_hex> <sig_hex>. In the relay's LedgerEntry, endpoint_sig = this signature, chain_head_hash = new_head, chain_seq = seq.
After OK + TUNNEL, the client<->relay TCP connection carries the session directly (no sid, the connection is the path). A client may hold several paths (one per relay) at once, all sharing one session_id and sequence space; each frame is duplicate-sent on every live path and the peer first-copy-wins-dedups. The relay translates C_* <-> MUX_* by adding/stripping the 16-byte sid.
| first byte | name | layout |
|---|---|---|
0xF0 | C_DATA | [0] ‖ seq(u32 LE) ‖ len(u16 LE) ‖ data[len] (7-byte header) |
0xF1 | C_ACK | [0] ‖ seq(u32 LE) (5 bytes): received-through |
0xF2 | C_PING | bare opcode |
0xF3 | C_PONG | bare opcode |
0xF4 | C_CLOSE | bare opcode; clean close, either end |
0xF5 | C_REPLAY | bare opcode; relay->client: replay the client->svc ring |
MAX_C_DATA = 16384. Identity fields:
session_id[16]: client-minted once per ask connect, identical on every path; the cross-path group key. On the wire in CONNECT ... <session_id_hex> and SESS ... <session_id_hex> ....tunnel_id[16] + secret[32]: per-path resume identity, relay-issued in TUNNEL.proof = Blake2b256_keyed(key = secret[32], message = tunnel_id[16]) -> 32 bytes
Client and relay compute it identically. On path drop the client re-knocks, sends RESUME_CLIENT <tunnel_id_hex> <proof_hex>, expects RESUME_OK; on RESUME_NO it falls back to a fresh CONNECT with the same session_id, so the agent groups it into the existing logical session. The live paths carry the stream with no gap throughout.
| constant | value | role |
|---|---|---|
TIMESTAMP_TOL_MS | 30 000 ms | SPA freshness window (v1 & v3) |
NONCE_CAP | 4096 | replay cache size (fail-closed) |
KNOCK_TIMEOUT_MS | 5 000 ms | deadline to complete a knock |
RESUME_GRACE_MS | 30 000 ms | how long a dropped session is held for resume |
AGENT/CLIENT_STALE_TIMEOUT_MS | 30 000 ms | idle -> dead -> reconnect |
MAX_PENDING_KNOCKS | 256 | in-flight knock cap |
MAX_AUTH_CONNS | 1024 | authenticated connection cap |
MAX_MUX_DATA / MAX_C_DATA | 16384 | max payload per frame |
The SPA first byte is the protocol version selector (0x01, 0x03). New versions (e.g. a post-quantum hybrid knock, 0x04) add a new first-byte case and a new total length; a relay that does not recognise the first byte drops the packet, so old and new coexist without negotiation. This is the extension point for the funded post-quantum milestone (Ed25519+ML-DSA credentials, X25519+ML-KEM sealed audit): hybrid, never a flag-day replacement.