<!-- SPDX-License-Identifier: Apache-2.0 -->
# askd protocol specification

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):
- **Ed25519**: SPA knock signatures, default CA credential signature, agent audit signatures.
- **ECDSA-P256** (`EcdsaP256Sha256`), alternate CA credential signature (hardware CAs).
- **X25519 SealedBox** (NaCl `crypto_box_seal`, anonymous-sender), sealed audit-record encryption.
- **Blake2b-256**: audit hash-chain (unkeyed) and the resume proof (keyed with a per-session secret).
- **SHA-256**: canonical credential fingerprint.

---

## 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

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

### 1.2 SPA response, 19 bytes

| off | size | field                    | order      |
|-----|------|--------------------------|------------|
| 0   | 1    | `version` = `0x01`       | n/a        |
| 1   | 2    | `port` (u16)             | big-endian |
| 3   | 16   | `token` (reserved, zero) | raw        |

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

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

---

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

| 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]` |

- **CA signature** covers bytes `[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.
- **Validity**: reject if `now < valid_from_ms` or `now > valid_to_ms`.
- **Fingerprint**: `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.
- CA public key encoding by hex length: 64 -> Ed25519 raw; 66/130 -> ECDSA-P256 SEC1 compressed/uncompressed.

### 2.1 Capability bits (`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 |

---

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

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

---

## 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 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`.

### 4.2 Control lines

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

### 4.3 `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) |

---

## 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 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`.

### 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

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

---

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