← askd

askd protocol specification

askd protocol · draft-01

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.

PROTOCOL.md (raw) test vectors Apache-2.0

Contents

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):


1. Single Packet Authorisation (SPA)

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).

1.1 SPA v1 knock, 89 bytes

offsizefieldorder
01version = 0x01n/a
116nonceraw
178ts_ms (i64)little-endian
2564sig (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.

1.2 SPA response, 19 bytes

offsizefieldorder
01version = 0x01n/a
12port (u16)big-endian
316token (reserved, zero)raw

1.3 SPA v3 knock, 505 bytes (carries a capability credential)

offsizefieldorder
01version = 0x03n/a
116nonceraw
178ts_ms (i64)little-endian
25416cred (sec. 2, the 416-byte Cred image)canonical
44164sig (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.


2. Capability credential (Cred), 416 bytes

A flat, CA-signed structure binding {identity, machine public key, capabilities, validity}. Every byte except the trailing signature is signed -> no malleable region.

offsizefieldnotes
01version = 0x03signed
11ca_sig_alg0 = Ed25519, 1 = ECDSA-P256
26_pad0
88valid_from_ms (i64)
168valid_to_ms (i64)
244caps (u32)capability bitmask (sec. 2.1)
281group_count (u8)
291target_count (u8)
302_pad1
3232identityNUL-padded hostname/CN
6432pubkeysubject Ed25519 public key
96128groups[8][16]NUL-padded labels
224128targets[8][16]NUL-padded labels
35264ca_sigCA signature over bytes [0..352]

2.1 Capability bits (caps, u32)

bitvaluenamegrants
00x01CAP_EXECone-shot: run a command
10x02CAP_PUTone-shot: upload a file
20x04CAP_GETone-shot: download a file
30x08CAP_CONNECTbe a client (ask connect)
40x10CAP_REGISTERbe an agent (askd)
50x20CAP_JUMPdial a client-chosen destination

3. Sealed audit record, 192 bytes

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):

offsizefield
08received_ms (i64)
81event_type (u8): e.g. 'K'=knock, 0x02=data-session-started
97_pad
1664fp_hex: credential fingerprint (auditor join key)
8032identity: NUL-padded hostname (empty for v1)

On-disk record (192 bytes):

offsizefield
032chain = Blake2b256(prev_chain ‖ ciphertext)
32160ciphertext = 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).


4. Relay mux protocol (post-knock control channel)

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.

4.1 Binary frames (relay <-> agent)

first bytenamelayout
0xFFMUX_DATA[0]sid[16]seq(u32 LE)len(u16 LE)data[len] (23-byte header)
0xFDMUX_ACK[0]sid[16]seq(u32 LE) (21 bytes): received-through seq
0xFEMUX_CLOSE[0]sid[16] (17 bytes)
0xFCMUX_REPLAY[0]sid[16] (17 bytes), relay->agent: replay the sid's ring

MAX_MUX_DATA = 16384.

4.2 Control lines

Session establishment (client->relay, gated by capability):

Control channel (relay <-> agent):

Handshake replies: OK then TUNNEL <tunnel_id_hex(32)> <secret_hex(64)>; resume: RESUME_OK / RESUME_NO.

4.3 LedgerEntry, 192 bytes (what a SEAL writes)

offsizefield
08received_ms (i64)
864cert_fp_hex
7216session_id
888chain_seq (u64)
9632chain_head_hash
12864endpoint_sig (agent Ed25519 signature, sec. 6)

5. Agent audit chain & SEAL construction

Each 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.


6. P6 multipath single-session frames (client <-> relay)

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 bytenamelayout
0xF0C_DATA[0]seq(u32 LE)len(u16 LE)data[len] (7-byte header)
0xF1C_ACK[0]seq(u32 LE) (5 bytes): received-through
0xF2C_PINGbare opcode
0xF3C_PONGbare opcode
0xF4C_CLOSEbare opcode; clean close, either end
0xF5C_REPLAYbare opcode; relay->client: replay the client->svc ring

MAX_C_DATA = 16384. Identity fields:

6.1 Resume proof

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.


7. Timing & anti-abuse constants

constantvaluerole
TIMESTAMP_TOL_MS30 000 msSPA freshness window (v1 & v3)
NONCE_CAP4096replay cache size (fail-closed)
KNOCK_TIMEOUT_MS5 000 msdeadline to complete a knock
RESUME_GRACE_MS30 000 mshow long a dropped session is held for resume
AGENT/CLIENT_STALE_TIMEOUT_MS30 000 msidle -> dead -> reconnect
MAX_PENDING_KNOCKS256in-flight knock cap
MAX_AUTH_CONNS1024authenticated connection cap
MAX_MUX_DATA / MAX_C_DATA16384max payload per frame

8. Versioning & forward compatibility

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.