First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
# Crypto Contract
> This file is the **single source of truth** for cryptography. Relay, plugin, and app MUST
> implement exactly what follows. Any divergence breaks interoperability. The words MUST / MUST NOT /
> SHOULD carry the RFC 2119 meaning. Verify your implementation against
> [test-vectors.md](test-vectors.md) **before** integrating.
Field encoding: see [index.md §5](index.md). In short: keys/signatures/ids/nonces in **lowercase
hex**, ciphertext in **standard base64 with padding**.
---
## 1. Domain Constants (NORMATIVE)
All strings are ASCII/UTF-8, without NUL terminator unless noted as `\x00`.
| Name | Value (bytes) | Use |
|------|---------------|-----|
| `KDF_SALT` | `"skald-kdf-v1"` | HKDF seed → keypair (§3) |
| `KDF_INFO_X25519` | `"x25519"` | HKDF info, X25519 branch (§3) |
| `KDF_INFO_ED25519` | `"ed25519"` | HKDF info, ed25519 branch (§3) |
| `SESSION_SALT` | `"skald-session-v1"` | HKDF shared_secret → aes_key (§5) |
| `SESSION_INFO` | `"aes-256-gcm"` | HKDF info, AEAD key (§5) |
| `NS_DOMAIN` | `"skald-namespace-v1"` | `namespace_id` derivation (§7) |
| `AUTH_DOMAIN` | `"skald-relay-auth-v1"` | Challenge-response signature (§8) |
| `NONCE_DIR_AGENT_TO_CLIENT` | `0x00 0x00 0x00 0x01` | Nonce prefix, agent→client direction (§6) |
| `NONCE_DIR_CLIENT_TO_AGENT` | `0x00 0x00 0x00 0x02` | Nonce prefix, client→agent direction (§6) |
| `PIPE_AUTH_DOMAIN` | `"skald-pipe-auth-v1"` | Pipe data-plane challenge signature ([pipe.md §3.1](pipe.md)) |
| `PIPE_KDF_SALT` | `"skald-pipe-v1"` | HKDF salt: ephemeral ECDH → per-pipe AES key ([pipe.md §4](pipe.md)) |
| `PIPE_KDF_INFO` | `"pipe-aes-256-gcm"` | HKDF info, per-pipe AES key ([pipe.md §4](pipe.md)) |
| `NONCE_DIR_PIPE_INITIATOR` | `0x00 0x00 0x00 0x03` | Nonce prefix, pipe initiator→responder ([pipe.md §4](pipe.md)) |
| `NONCE_DIR_PIPE_RESPONDER` | `0x00 0x00 0x00 0x04` | Nonce prefix, pipe responder→initiator ([pipe.md §4](pipe.md)) |
Algorithms: **X25519** (RFC 7748), **Ed25519** (RFC 8032), **HKDF-SHA256** (RFC 5869),
**AES-256-GCM** (NIST SP 800-38D), **SHA-256** (FIPS 180-4).
> The **pipe** (relayed byte-stream, [pipe.md](pipe.md)) reuses this entire suite — X25519 ECDH,
> HKDF, AES-256-GCM with the `DIR ‖ counter` nonce (§6) — keyed by a **per-pipe ephemeral** DH
> (Perfect Forward Secrecy), with `aad = connection_id`. No new primitives.
---
## 2. Persistent Material: the Seed
Every actor with a cryptographic identity (agent and each client) holds **one single persistent
secret**: a **32-byte seed** generated from CSPRNG.
- Agent: `data/relay/seed`, 32-byte binary file, permissions `0600`. Generated on first start.
- iOS client: 32 bytes in Keychain, attribute `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`.
- Android client: 32 bytes in Keystore / EncryptedSharedPreferences (hardware-backed if available).
Two keypairs are derived from this seed (§3). The seed MUST NOT leave the device and MUST NOT
ever be transmitted. Private keys are regenerated from the seed on each startup; they are not
persisted separately.
> **Why two keypairs?** Ed25519 is for **signing** (authentication toward the relay).
> X25519 is for **ECDH** (E2E key agreement). They are related curves with distinct roles and APIs
> on all platforms (CryptoKit separates them: `Curve25519.Signing` vs `Curve25519.KeyAgreement`).
> **Never** convert an ed25519 key into X25519 by reinterpreting the bytes: this is cryptographically
> wrong. Both are derived independently from the seed.
---
## 3. Key Derivation from Seed (NORMATIVE)
Identical across all platforms. `HKDF` = HKDF-SHA256, 32-byte output.
```
x25519_priv = HKDF(ikm = seed, salt = KDF_SALT, info = KDF_INFO_X25519, len = 32)
ed25519_priv = HKDF(ikm = seed, salt = KDF_SALT, info = KDF_INFO_ED25519, len = 32)
```
- `x25519_priv` (32 bytes) is the X25519 private **scalar**. Libraries apply RFC 7748 *clamping*
internally; do not clamp manually. `x25519_pub = X25519(x25519_priv, basepoint)`.
- `ed25519_priv` (32 bytes) is the **Ed25519 seed** (the 32-byte "private key" of RFC 8032).
`ed25519_pub` (32 bytes) is derived from it per RFC 8032.
> Terminology note: in Ed25519, the 64-byte "private key" is `seed(32) ‖ pub(32)`. Here the secret
> material is the **32-byte seed** (`ed25519_priv` above). Do not confuse the 32 bytes of *our* seed
> (§2) with the 32 bytes of the *Ed25519 seed* (HKDF output): they are different things.
### Rust (agent / relay-side verification)
```rust
use hkdf::Hkdf;
use sha2::Sha256;
use ed25519_dalek::SigningKey; // ed25519-dalek = "2"
use x25519_dalek::{StaticSecret, PublicKey}; // x25519-dalek = "2"
fn derive_keys(seed: &[u8; 32]) -> (SigningKey, StaticSecret) {
let hk = Hkdf::<Sha256>::new(Some(b"skald-kdf-v1"), seed);
let mut x = [0u8; 32];
hk.expand(b"x25519", &mut x).unwrap();
let x25519_priv = StaticSecret::from(x); // internal clamping
let mut e = [0u8; 32];
hk.expand(b"ed25519", &mut e).unwrap();
let ed25519_priv = SigningKey::from_bytes(&e);
(ed25519_priv, x25519_priv)
}
// pub keys:
// ed25519_pub = signing_key.verifying_key().to_bytes() // 32B
// x25519_pub = PublicKey::from(&x25519_priv).to_bytes() // 32B
```
### Swift (iOS, CryptoKit)
```swift
import CryptoKit
func deriveKeys(seed: Data) -> (signing: Curve25519.Signing.PrivateKey,
agreement: Curve25519.KeyAgreement.PrivateKey) {
let ikm = SymmetricKey(data: seed)
let salt = Data("skald-kdf-v1".utf8)
let xRaw = HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: salt,
info: Data("x25519".utf8), outputByteCount: 32)
let eRaw = HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: salt,
info: Data("ed25519".utf8), outputByteCount: 32)
let agreement = try! Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: xRaw.withUnsafeBytes { Data($0) })
let signing = try! Curve25519.Signing.PrivateKey(
rawRepresentation: eRaw.withUnsafeBytes { Data($0) })
return (signing, agreement)
}
```
### Kotlin (Android — reference)
Use **BouncyCastle / Tink**: `HKDFBytesGenerator(SHA256Digest)` with the same salt/info, then
`X25519PrivateKeyParameters` and `Ed25519PrivateKeyParameters` from the 32 derived bytes.
---
## 4. ECDH — Key Agreement (X25519, ONLY path)
The agent and each client exchange their **X25519 public key** (the agent via QR; the client via
the pairing frame — see [relay-protocol.md](relay-protocol.md)). The shared secret:
```
shared_secret = X25519(my_x25519_priv, peer_x25519_pub) // 32 bytes
```
It is symmetric: `X25519(a_priv, b_pub) == X25519(b_priv, a_pub)`. **MUST** always and only use
X25519 keys. Ed25519 keys NEVER enter ECDH.
```rust
let shared = my_x25519_priv.diffie_hellman(&PublicKey::from(peer_x25519_pub_bytes));
let shared_secret: [u8; 32] = *shared.as_bytes();
```
```swift
let peerPub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerX25519PubBytes)
let shared = try myAgreementPriv.sharedSecretFromKeyAgreement(with: peerPub)
// `shared` is a SharedSecret; do NOT use it raw: pass through HKDF (§5).
```
> **Point validation.** Standard libraries (x25519-dalek, CryptoKit) handle low-order points;
> an implementation that does not MUST reject an all-zero shared secret.
---
## 5. AEAD Key Derivation (HKDF)
The raw shared secret is never used directly as a key. It is derived:
```
aes_key = HKDF(ikm = shared_secret, salt = SESSION_SALT, info = SESSION_INFO, len = 32)
```
```rust
let hk = Hkdf::<Sha256>::new(Some(b"skald-session-v1"), &shared_secret);
let mut aes_key = [0u8; 32];
hk.expand(b"aes-256-gcm", &mut aes_key).unwrap();
```
```swift
let aesKey = shared.hkdfDerivedSymmetricKey(using: SHA256.self,
salt: Data("skald-session-v1".utf8),
sharedInfo: Data("aes-256-gcm".utf8),
outputByteCount: 32)
```
`aes_key` is **per-peer** (one per agent↔client pair) and static for the life of the pairing
(no PFS in the current protocol).
---
## 6. AEAD — AES-256-GCM with Counter Nonce and AAD (NORMATIVE)
**All** E2E messages are encrypted this way. There is no separate MAC: **GCM is already
authenticated**. (No separate HMAC — it would be redundant and violate key-separation.)
### 6.1 Nonce — Monotonic Counter, NOT Random
The GCM nonce is **12 bytes** and is built deterministically to prevent reuse and provide
**anti-replay**:
```
nonce (12B) = DIR (4B) ‖ counter (8B, big-endian)
```
- `DIR` = `NONCE_DIR_AGENT_TO_CLIENT` if the encryptor is the agent, `NONCE_DIR_CLIENT_TO_AGENT`
if it is the client. Ensures the two directions never collide even though they share `aes_key`.
- `counter` is a **strictly increasing** 64-bit integer, **persisted per-peer and per-direction**.
Starts at `1`. Increments by 1 per sent message. MUST be persisted **before** sending (so a
crash cannot cause reuse).
The **receiver** maintains `last_seen_counter` for (peer, direction) and MUST reject any message
with `counter <= last_seen_counter` (replay or reorder). Under FIFO store-and-forward delivery,
counters arrive in order; a forward gap is allowed (messages lost), a value `<=` is not.
> Consequence: counters are the primary **anti-replay state**. They survive reconnections and
> restarts because they are persisted. If the send counter is irreversibly reset (e.g. seed
> restored without state), a **re-pairing** is required (new `aes_key`, counters reset together).
### 6.2 AAD — Routing Binding
The AAD (Additional Authenticated Data) binds the ciphertext to routing metadata, so a malicious
relay relabelling `from`/`to` causes decryption to **fail**:
```
AAD (96B) = namespace_id_raw (32B) ‖ from_pubkey (32B) ‖ to_pubkey (32B)
```
- `namespace_id_raw` = the 32 raw bytes of the hash from §7 (NOT the hex string).
- `from_pubkey`, `to_pubkey` = **ed25519** public keys (32 raw bytes) of sender and recipient
(same values used for routing in the envelope).
- The receiver reconstructs the AAD from the `from`/`to` fields of the received envelope and its
own `namespace_id`. If they do not match those used in encryption → invalid GCM tag → discard.
### 6.3 Encrypted Block Format
```
sealed = ciphertext ‖ tag(16B) // GCM "combined" output, WITHOUT nonce
```
The `nonce` travels **in plaintext** in a separate envelope field (it is public by definition;
its integrity is guaranteed because GCM uses it as an authenticated IV). On the wire (inside the
E2E JSON payload, before the framing of §… is applied):
```json
{ "nonce": "<hex 24>", "ciphertext": "<base64 of (ciphertext‖tag)>" }
```
In the protobuf transport (`Message` frame) the fields are raw bytes — no hex, no base64.
### 6.4 Rust
```rust
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use aes_gcm::aead::{Aead, Payload};
fn seal(aes_key: &[u8;32], dir: [u8;4], counter: u64,
aad: &[u8;96], plaintext: &[u8]) -> (Vec<u8> /*nonce*/, Vec<u8> /*sealed*/) {
let mut nonce = [0u8; 12];
nonce[..4].copy_from_slice(&dir);
nonce[4..].copy_from_slice(&counter.to_be_bytes());
let cipher = Aes256Gcm::new(aes_key.into());
let sealed = cipher.encrypt(Nonce::from_slice(&nonce),
Payload { msg: plaintext, aad }).expect("encrypt");
(nonce.to_vec(), sealed)
}
fn open(aes_key: &[u8;32], nonce: &[u8;12], aad: &[u8;96], sealed: &[u8]) -> Option<Vec<u8>> {
let cipher = Aes256Gcm::new(aes_key.into());
cipher.decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad }).ok()
}
```
### 6.5 Swift
```swift
func seal(aesKey: SymmetricKey, dir: [UInt8], counter: UInt64,
aad: Data, plaintext: Data) throws -> (nonce: Data, sealed: Data) {
var n = Data(dir) // 4B
var be = counter.bigEndian
n.append(Data(bytes: &be, count: 8)) // +8B = 12B
let nonce = try AES.GCM.Nonce(data: n)
let box = try AES.GCM.seal(plaintext, using: aesKey,
nonce: nonce, authenticating: aad)
// box.ciphertext box.tag == "sealed"
return (n, box.ciphertext + box.tag)
}
func open(aesKey: SymmetricKey, nonce: Data, aad: Data, sealed: Data) throws -> Data {
let ct = sealed.prefix(sealed.count - 16)
let tag = sealed.suffix(16)
let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: nonce),
ciphertext: ct, tag: tag)
return try AES.GCM.open(box, using: aesKey, authenticating: aad)
}
```
### 6.6 Static Key Operational Limit
With a static `aes_key` and a 64-bit counter there is no practical risk of nonce exhaustion or
reuse (the counter is unique by construction). The NIST limit for AES-GCM with a single key is
~2³² messages before considering rotation: unreachable for this workload (approvals/clarifications).
Key rotation via **re-pairing** is nevertheless recommended if compromise is suspected.
---
## 7. `namespace_id` Derivation (NORMATIVE)
The namespace id is **immutably bound** to the agent's identity key — preventing takeover without
requiring relay-side state to guarantee it:
```
namespace_id_raw = SHA256( NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub(32B) ) // 32 bytes
namespace_id = hex(namespace_id_raw) // 64 chars
```
- The relay, upon receiving the agent's auth, MUST verify that `namespace_id` derives from the
presented `agent_ed25519_pub` and that the challenge signature is valid under that key.
- The client, from the QR, MUST verify `namespace_id == hex(SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub))`
using the `agent_ed25519_pub` from the QR. This way it does not trust the relay for the id.
- `namespace_id_raw` is also the value used in the AAD (§6.2).
---
## 8. Challenge-Response (Key Ownership Proof)
On WS open the **relay speaks first** and sends a challenge. The connecting peer (any role) signs
and responds. Transport details in [relay-protocol.md](relay-protocol.md); here the primitive.
```
challenge_nonce = 32 random bytes (CSPRNG on the relay side), sent as raw bytes in protobuf
msg_to_sign = AUTH_DOMAIN ‖ 0x00 ‖ challenge_nonce_raw(32B)
signature = Ed25519_sign(ed25519_priv, msg_to_sign) // 64 bytes
```
The relay verifies `Ed25519_verify(pub, signature, msg_to_sign)`. The **domain separation**
(`AUTH_DOMAIN`) prevents an auth signature from being reusable in other contexts.
```rust
let mut msg = Vec::with_capacity(20 + 32);
msg.extend_from_slice(b"skald-relay-auth-v1");
msg.push(0x00);
msg.extend_from_slice(&challenge_nonce_raw); // 32B
let sig = ed25519_priv.sign(&msg); // ed25519-dalek: Signer
```
```swift
var msg = Data("skald-relay-auth-v1".utf8); msg.append(0x00); msg.append(challengeNonceRaw)
let sig = try signingPriv.signature(for: msg) // 64B
```
> Ed25519 internally hashes the message: do **not** pre-hash with SHA-256. Sign
> `AUTH_DOMAIN ‖ 0x00 ‖ nonce` directly.
---
## 9. Pairing Token (Capability Bearer, NOT a Signature)
The `pairing_token` is a **single-use bearer secret**, not a signature:
```
pairing_token = 32 random bytes (CSPRNG on the agent side), as raw bytes in protobuf
```
- The agent generates it on each `pairing_start`, puts it in the QR, and sends it to the relay
(`PairingStart` frame). 256-bit entropy: not guessable.
- The relay treats it as an opaque blob: **byte-for-byte** comparison, **expiry**, **single-use**
(consumed on first successful pairing), valid only while the namespace is in pairing mode.
- The client presents it in the pairing frame. It cannot verify it cryptographically (bearer token):
security comes from **out-of-band QR** + **short TTL** + **single-use** + **explicit agent confirmation**
of the new device.
> No Ed25519 signature on the token: nobody would verify it (security theater). A 256-bit random
> secret is simpler and equally strong as a capability.
---
## 10. Key Storage
### Agent (filesystem + DB)
```
data/relay/
└── seed # 32 bytes, 0600. The only persistent secret.
```
DB table `relay_clients` (see [../plugins/mobile-connector.md](../plugins/mobile-connector.md)):
stores per-client `x25519_pub`, `send_counter`, `recv_counter`. `shared_secret` and `aes_key`
are **never persisted**: re-derived from `seed` + `x25519_pub` on each startup (smaller attack
surface; negligible cost).
### Client (Keychain / Keystore)
- `seed` (32B) with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, shared with the
**Notification Service Extension** via **Keychain Access Group** (the NSE must be able to
derive `aes_key`).
- `namespace_id`, `relay_url`, `agent_ed25519_pub`, `agent_x25519_pub`, `send_counter`,
`recv_counter`: in the same shared storage.
- App uninstall → keys lost → re-pairing required.
---
## 11. Algorithm Summary
| Operation | Algorithm | Input → Output |
|-----------|-----------|----------------|
| Seed | CSPRNG | → 32B |
| Key derivation | HKDF-SHA256 (`KDF_SALT`, info) | seed 32B → x25519_priv 32B, ed25519_priv 32B |
| ECDH | X25519 | my_x25519_priv + peer_x25519_pub → shared 32B |
| AEAD key derivation | HKDF-SHA256 (`SESSION_SALT`, `SESSION_INFO`) | shared 32B → aes_key 32B |
| Encryption | AES-256-GCM | aes_key + nonce(DIR‖counter) + AAD(96B) → ciphertext‖tag |
| `namespace_id` | SHA-256 (`NS_DOMAIN`) | agent_ed25519_pub → 32B (hex) |
| Auth | Ed25519 sign/verify (`AUTH_DOMAIN`) | ed25519_priv + challenge → sig 64B |
| Pairing token | CSPRNG | → 32B single-use bearer |
## 12. Security Considerations
- **PFS**: not in the current protocol. Static `aes_key` → traffic capture + later seed theft =
plaintext for historical messages. Roadmap: ephemeral ECDH per session.
- **Replay**: prevented by monotonic counter (§6.1) + `request_id` idempotency
([payloads.md](payloads.md)) + `ts` freshness.
- **Malicious relay**: cannot read content (E2E) and cannot relabel `from`/`to` (AAD, §6.2);
it can only **drop/hold/reorder** → mitigated by fail-safe + TTL pending on the agent side.
- **Timing**: Ed25519 and AES-GCM are constant-time in the reference implementations
(ed25519-dalek, aes-gcm with AES-NI feature, CryptoKit). Tag/token comparisons MUST be
constant-time (`subtle` / `constantTimeAreEqual`).
- **Input validation**: reject malformed hex/base64, wrong lengths, and every failed decryption
**without** distinguishing the cause in error messages.
+114
View File
@@ -0,0 +1,114 @@
# Tool Description & Push Delivery (`describe` + `blocks` + APNs)
_Normative for approval rendering on the client side._
> How the app receives and displays **what** it is approving, and how the push notification stays
> lightweight. Primarily concerns **approvals** (tool arguments are hard to read as raw JSON);
> clarifications already carry a human-written `question` from the LLM.
>
> This file defines the **wire contract** (what the client receives). How the blocks are
> produced on the agent side (built-in vs MCP, templates) is in the agent's tool description
> infrastructure.
---
## 1. Two Representations, One Item
Every `approvals[]` entry in an `inbox_update` ([payloads.md §3.1](payloads.md)) carries **two**
views of the same tool call:
| Field | What it is | Used for |
|-------|-----------|----------|
| `summary` | Short human string, generated by `describe(Short)` on the agent (e.g. *"Send an email to mario@acme.com"*) | Card row, **push notification**, conversation log |
| `blocks` | **Structured** parameter description, generated by `describe_view(args)` | Detail screen (forms/tables/diffs) |
| `arguments` | Raw args (tool's JSON) | Final fallback / debug |
`summary` is the source of truth for "narrow" surfaces (notification, badge); `blocks` for the
detail screen. `arguments` remain as a safety net.
---
## 2. `blocks` Schema (wire)
A tool call is described as a **list of typed blocks**. The vocabulary is **small and stable**:
tools are unlimited, block types are not. Each client maps types to its native widgets *once*,
and it works for any present and future tool.
```json
{
"v": 1,
"summary": "Send an email to to@mail.com",
"blocks": [
{ "type": "key_value", "key": "Email-To", "value": "to@mail.com", "value_type": "email" },
{ "type": "field", "label": "Subject", "value": "Q3 Estimate", "value_type": "string" },
{ "type": "block", "label": "Body", "value": "…body…", "value_type": "text" }
]
}
```
Example for `write_file`:
```json
{
"v": 1,
"summary": "Write /path/x.rs",
"blocks": [
{ "type": "key_value", "key": "File", "value": "/path/x.rs", "value_type": "path" },
{ "type": "block", "label": "Diff", "value": "= same\n- old line\n+ new line", "value_type": "diff" }
]
}
```
**`type`** = layout hint (three values suffice):
| `type` | Rendering |
|--------|-----------|
| `key_value` | Compact `key: value` row |
| `field` | Labelled field, value on one line |
| `block` | Extended content with label (multi-line / dedicated viewer) |
**`value_type`** = value semantics → widget + formatting:
`string` · `text` · `markdown` · `code` · `diff` · `command` · `email` · `url` · `path` · `json` · `number` · `boolean` · `datetime` · `secret`
Rendering notes:
- `diff` → diff viewer (green/red). `command` / `code` → monospace. `email` / `url` → tappable.
- `secret`**masked by default** (e.g. API key in args).
- Block fields: `key` (for `key_value`) or `label` (for `field`/`block`), `value`, `value_type`.
---
## 3. Delivery Model: Lightweight Push, Detail via WS
**Principle: the push never carries `blocks`/`arguments`. Only the `summary`.**
| Channel | What travels |
|---------|-------------|
| **WS** (live or `inbox_request` on tap) | **Complete** `inbox_update`: `summary` + `blocks` + `arguments` |
| **APNs/FCM push** | A minimal `notification` (kind §3.2 in payloads.md) with `body` = `summary` (`describe`), **no blocks/args** |
Flow: the app receives the notification with the `summary` line → user taps → app opens WS and
sends `inbox_request` ([payloads.md §4.6](payloads.md)) → receives the complete `inbox_update`
with `blocks` → shows the detail screen.
### Why (zero-trust + size)
- The relay is **zero-trust**: it cannot read the encrypted content, so it **cannot** extract
`summary` from the `inbox_update` blob. It is the **agent** that emits, for the push, a
lightweight `notification` payload (E2E, decrypted by the NSE) containing only the `summary`.
- This way the push **always** fits under the content-in-push threshold (3500B b64,
[server.md §5](server.md)) → notification always readable, without the content-vs-wake juggling
needed for rich payloads.
- The `aps.alert` in plaintext ("Skald / Action required") remains the generic fallback visible
to Apple; the real text (`summary`) is in the encrypted blob that the NSE replaces.
---
## 4. Forward-Compat & Degradation
- `v` at the top of `blocks`. An unknown `type`/`value_type` is not an error: the client
degrades to `key_value` / raw string (never crash).
- If `blocks` is absent (older agent, or tool with no description), the client shows `summary`
and optionally raw `arguments`. Fully backward-compatible.
- Non-form surfaces (e.g. Telegram) **flatten** blocks to text (`key: value`, diff in monospace):
every block is linearisable by construction.
+104
View File
@@ -0,0 +1,104 @@
# E2E Plaintext Framing
> Defines the structure of the **bytes that are encrypted** in the `ciphertext` field of the
> `Message` frame ([relay-protocol.md §6](relay-protocol.md)). **The cryptography does not
> change** ([crypto.md](crypto.md)): a blob of bytes is always encrypted with AES-256-GCM. What
> changes is *what* those bytes are: a **versioned frame** wrapping the JSON payload.
>
> The relay remains **blind**: it sees only ciphertext, nothing about versions or compression.
---
## 1. Structure
The **plaintext** (what is encrypted) is:
```
plaintext = version (1 byte) ‖ comp (1 byte) ‖ payload
```
| Field | Byte | Values | Meaning |
|-------|------|--------|---------|
| `version` | 1 | `0x01` \| `0x02` | Framing version. `0x01` = JSON app payload; `0x02` = **pipe signaling** (MsgPack, see below). Unknown value → receiver discards with log. |
| `comp` | 1 | `0x00` \| `0x01` | Compression algorithm applied to `payload` (§2). |
| `payload` | N | — | The content: **JSON UTF-8** ([payloads.md](payloads.md)) for `0x01`, **MsgPack `PipeSignal`** ([pipe.md §2](pipe.md)) for `0x02`; optionally compressed. |
> **`version 0x02` (pipe signaling).** Reserved for the pipe control plane ([pipe.md](pipe.md)):
> `0x02 ‖ 0x00 ‖ <MsgPack PipeSignal>` (uncompressed). It rides this same E2E channel; a receiver
> peeks the first byte to route `0x02` to its pipe layer and `0x01` to the JSON app path. The
> existing `decompress_payload` still only accepts `0x01` — the pipe layer handles `0x02` itself.
`version` and `comp` are **in plaintext inside the plaintext** (readable only after decryption):
they cannot go in the AAD or outside the ciphertext, or the relay would see them. They are
integrity-protected by the GCM tag along with the rest.
> **Two versioning planes, do not confuse.** `version` (this byte, `0x01`) versions the
> **framing** (the binary envelope). The JSON field `v` inside the `payload`
> ([payloads.md §1](payloads.md)) versions the **payload schema**. They are independent: framing
> can evolve while a `kind`'s schema stays fixed, and vice versa. In these documents "version" =
> framing byte; "`v`" = payload schema. (The name `v` is unchanged from the original design for
> consistency with existing payloads.)
## 2. Compression
| `comp` | Algorithm | Notes |
|--------|-----------|-------|
| `0x00` | none | `payload` = JSON UTF-8 as-is. |
| `0x01` | **zlib / DEFLATE** (RFC 1950/1951) | Default for large payloads. Safe interop: Rust `flate2` ↔ iOS `Compression` framework (`COMPRESSION_ZLIB`). |
| `0x02…` | _reserved_ | E.g. `lz4` in the future. Addable without breakage: a receiver that does not know a `comp` value discards with log. |
Rules:
1. **Compress-then-encrypt, always in this order.** The ciphertext is not compressible; compressing
after would give no gain.
2. Compression is **optional on the sender side**, **mandatory on the receiver side**: anyone
receiving MUST handle both `0x00` and `0x01`.
3. **Threshold**: compress only if `len(payload)` exceeds ~1 KiB. Below that, the zlib header
overhead wipes out any gain → use `0x00`.
4. Compression operates on `payload` **only**, not on the two header bytes.
## 3. Decoding (receiver side)
For each decrypted `Message` envelope:
1. AES-GCM → obtain the `plaintext` blob (AAD/anti-replay identical to the crypto contract,
[crypto.md §6](crypto.md)).
2. Read `version = plaintext[0]`. If `!= 0x01` → discard with log.
3. Read `comp = plaintext[1]`. If unknown → discard with log.
4. `body = plaintext[2:]`; if `comp == 0x01` → decompress (zlib).
5. Parse `body` as JSON; validate `v`/`kind` ([payloads.md §6](payloads.md)); apply action
idempotently.
## 4. No Version Disambiguation
There is no v1/v2 transport coexistence in production (clean break, no distributed v1 clients).
Therefore **no disambiguation trick is needed**: every payload is a versioned frame (`version = 0x01`).
A receiver reading a `version` different from `0x01` discards with log (§3 step 2).
## 5. Sizes & Limits
The `ciphertext` travels as **raw bytes** in the `Message` protobuf
([relay-protocol.md §10](relay-protocol.md)): **no base64**, so the frame limit applies almost
entirely to the ciphertext. Full chain:
```
payload →(zlib?)→ body →(GCM: +16B tag)→ raw ciphertext →(protobuf: +~tens of bytes)→ frame
```
**Normative constants** (frame limit differs per channel):
```
# Standard frame 64 KiB (control + Message live=false store-and-forward)
MAX_CIPHERTEXT_BYTES = 65000 # raw ciphertext (GCM tag included)
# Live frame 512 KiB (Message live=true, authenticated connection)
MAX_LIVE_CIPHERTEXT_BYTES = 524000 # raw ciphertext (GCM tag included)
```
Values leave a few hundred bytes of margin for the protobuf envelope (`peer` 32B, `nonce` 12B,
field tags, `live`) under the respective `MAX_*_FRAME_BYTES`. Anyone composing a large payload
**MUST** close the packet before exceeding `MAX_LIVE_CIPHERTEXT_BYTES`, estimating the size
**after** compression and tag.
Compression helps fit more data per frame: health-type data (JSON numeric and repetitive)
typically compresses 510×.
+173
View File
@@ -0,0 +1,173 @@
# Skald Remote Control — Architecture & Index
> **Purpose.** Specify, unambiguously, how to build the system that lets a mobile app (iOS/Android)
> remotely control a person's **Skald instance** — even when Skald runs at home behind NAT.
> Documents are written as **implementation contracts**: a coding agent must be able to implement
> its component (relay, plugin, app) by reading only these files and achieve byte-for-byte
> interoperability with all other components.
## 1. The Problem
Skald is self-hosted: anyone who installs it locally ends up **behind NAT**, unreachable from the
internet. We want a mobile app that:
1. receives **push notifications** when Skald needs human input (approvals, clarifications);
2. **responds** (approve / reject / clarify) even with Skald behind NAT.
Push notification systems (APNs/FCM) do not allow an arbitrary sender to push to someone else's
app: a component holding the push credentials is required. Hence the **relay**.
The entire architecture exists **only** to solve: (a) bidirectional communication through NAT,
(b) push notifications. Nothing more. The relay is designed to be **content-blind**.
> **What this is NOT.** Not a chat, not a streaming system, not a sub-agent protocol.
> The mobile client is a **remote control surface** (a human-in-the-loop remote) for the
> **single Skald instance** that owns the namespace. The approvals and clarifications the client
> sees are those exposed by that Skald instance through its Inbox; how Skald generates them
> internally (tools, scheduled jobs, etc.) is an internal detail outside this spec.
## 2. Actors
| Actor | Abbr | Role |
|-------|------|------|
| **Skald Agent** | `agent` | The Skald instance. **Namespace owner.** Holds the identity key. Opens a permanent WS connection to the relay. Encrypts/decrypts E2E. |
| **Relay Client** | `agent` impl | `crates/skald-relay-client/`: the **standalone, payload-agnostic** library that implements the `agent` role — keys, WS v2 transport, E2E crypto, anti-replay counters, pairing, device authorization, SQLite persistence. Exchanges opaque decrypted bytes via `RelayEvent`; depends only on `skald-relay-common` (never on Skald/`core-api`). |
| **Mobile Connector Plugin** | — | The thin **application** crate inside Skald (`crates/plugin-mobile-connector/`) on top of the relay client: it owns the JSON payload schemas, the Inbox↔relay routing, the authorization policy, and the QR endpoint. The bridge to mobile apps; today via relay, in the future also via direct transports (TCP/port-forward). See [server.md](server.md) and [../plugins/mobile-connector.md](../plugins/mobile-connector.md). |
| **Relay Server** | `relay` | The only centralised component. APNs/FCM bridge, store-and-forward, namespace routing. **Zero-trust on content.** See [server.md](server.md). |
| **Shared Crate** | — | `crates/skald-relay-common/`: protocol frame types (protobuf) + cryptographic primitives, shared **byte-for-byte** between relay, relay client, and server (no duplication). |
| **Client** | `client` | Mobile app (iOS/Android). Pairs via QR, encrypts/decrypts E2E, shows Inbox, responds. Implementation documented in the iOS app repository. |
A **namespace** is the isolated zone of one person: their agent + their authorised clients.
Different namespaces are unaware of each other. Multiple devices can share a namespace
(iPhone + iPad).
## 3. Architecture
```
Home / NAT Cloud Pocket
┌───────────────────────┐ ┌────────────────────────┐ ┌──────────────────────┐
│ Skald Agent │ │ Relay Server │ │ Client (iOS/Android) │
│ (namespace owner) │ │ (zero-trust) │ │ │
│ ┌──────────────────┐ │ WSS │ • APNs/FCM bridge │ WSS │ ┌─────────────────┐ │
│ │ Mobile Connector │◀─┼───────▶│ • store-and-forward │◀───▶│ │ CryptoEngine │ │
│ │ ed25519 + X25519 │ │ (perm.)│ • namespace routing │ │ │ ed25519 + X25519 │ │
│ └──────────────────┘ │ │ • does NOT decrypt │ │ └─────────────────┘ │
└───────────────────────┘ └───────────┬────────────┘ └──────────────────────┘
│ push (wake / encrypted blob)
APNs (Apple) / FCM (Google)
```
- All actors connect to the **same** WebSocket endpoint on the relay.
- Agent↔client communication is **end-to-end encrypted**: the relay sees only opaque blobs.
- The relay routes by public key within the namespace and, if the recipient is offline,
queues and sends a push.
## 4. Threat Model (read before implementing)
### 4.1 Guarantees
| Guarantee | Mechanism |
|-----------|-----------|
| **Content confidentiality** end-to-end | AES-256-GCM with key derived from ECDH X25519. The relay has no key. |
| **Content integrity + authenticity** | GCM tag + binding of `from`/`to`/`namespace_id` in AAD. A relay that flips one byte breaks decryption. |
| **Peer authentication at pairing** | The agent's X25519 public key arrives **out-of-band** via QR (TOFU). The E2E channel is authenticated toward whoever controls that key. |
| **Anti-replay** | Per-direction **monotonic counter** nonce + `request_id` idempotency + `ts` freshness. See [crypto.md](crypto.md). |
| **Key ownership proof** (to the relay) | Challenge-response with Ed25519 signature, with domain separation. |
| **No namespace takeover** | `namespace_id = SHA256(domain ‖ agent_ed25519_pub)`: the id is immutably bound to the key. |
| **Device authorisation controlled by the owner** | Only the agent decides the authorised list. Pairing produces a **pending** device until the agent confirms. Pairing token is **single-use**. |
### 4.2 What the Relay CAN See and Do (declared limits)
> "Zero-trust" here means **content-confidential**, **not** metadata-private. This must be stated
> explicitly in the privacy policy.
| The relay sees | Notes |
|----------------|-------|
| Public keys of agent and clients | Public identifiers, not linked to real identities. |
| `device_token` (APNs/FCM), `platform` | Required for push delivery. |
| IP addresses (TCP/TLS layer) | Unavoidable. |
| Relationship graph (who talks to whom), timing, message sizes | Routing metadata. The relay learns **when** you are active. |
| The relay does NOT see | Why |
|------------------------|-----|
| Content / message type | E2E encrypted; the AAD is authenticated but the routing fields are only pubkeys. |
| Detailed `device_info` (model, OS, app version) | Sent **E2E** to the agent after pairing (`hello`), not to the relay. |
| The relay CAN do (and we defend against it) | Defence |
|---------------------------------------------|---------|
| **Drop / hold / reorder** messages and pushes | A lost approval = no action (fail-safe). Pending items have **TTL on the agent side**: a held-then-released "approve" is **no longer acted upon** after expiry. |
| **Replay** an encrypted blob | Monotonic counter per direction + `request_id` idempotency: a replay is discarded. |
| **Relabel** `from`/`to` | `from`/`to`/`namespace_id` are in the GCM AAD: decryption fails. |
### 4.3 Out of Scope (assumptions)
- **Compromised host** (agent or device): if the attacker has the seed, they have everything. Unavoidable.
Mitigation: minimal-permission storage / Keychain `ThisDeviceOnly`.
- **Apple/Google push channel compromise**: content stays E2E-protected; at worst availability is lost.
- **Perfect Forward Secrecy**: **not** in the current protocol (static shared secret after pairing). Roadmap.
Accepted consequence: traffic capture + later seed theft = plaintext for historical messages.
## 5. Encoding Conventions (NORMATIVE — apply to all files)
To eliminate ambiguity between implementations, the encoding of **every** binary field is fixed here.
| Data type | Wire encoding (JSON) | Example |
|-----------|----------------------|---------|
| Public keys (ed25519, X25519), 32 bytes | **lowercase hex**, 64 chars | `"3b6a…"` |
| Ed25519 signatures, 64 bytes | **lowercase hex**, 128 chars | `"9f1c…"` |
| `namespace_id` (SHA-256, 32 bytes) | **lowercase hex**, 64 chars | `"a17e…"` |
| `pairing_token` (32 bytes random) | **lowercase hex**, 64 chars | `"5d20…"` |
| Challenge `nonce` (32 bytes random) | **lowercase hex**, 64 chars | `"c4f0…"` |
| AEAD `nonce` (12 bytes) | **lowercase hex**, 24 chars | `"000000016a…"` |
| **Ciphertext** AEAD (variable, ciphertext‖tag) | **standard base64 with padding** (RFC 4648 §4) | `"q1B2…=="` |
Rules:
1. **Hex for fixed-length material** (keys, signatures, ids, nonces): easy to compare and debug.
Hex MUST always be lowercase; an implementation receiving uppercase MUST accept it but MUST emit lowercase.
2. **Standard base64 (not url-safe), with padding** for variable-length blobs (only ciphertext qualifies).
3. These rules apply to **JSON payloads** (the E2E content). The relay transport layer uses protobuf
binary frames where all binary fields travel as **raw bytes** — no hex, no base64.
4. Application timestamps: **unix epoch in milliseconds** (integer). Relay routing timestamps:
ISO-8601 UTC string (advisory only).
5. Unknown fields in JSON are ignored (forward-compat). Integers without decimal point.
## 6. Document Map
| File | Content | Primary audience |
|------|---------|-----------------|
| [index.md](index.md) | This file: vision, actors, threat model, encoding | Everyone |
| [crypto.md](crypto.md) | **Crypto contract**: seed, key derivation, ECDH, HKDF, AEAD, AAD, anti-replay, signatures | All implementors |
| [relay-protocol.md](relay-protocol.md) | **WebSocket protocol**: protobuf transport, auth, pairing, message envelope, live channel, presence, errors, limits | Relay, plugin, app |
| [framing.md](framing.md) | **E2E plaintext framing** `[version][comp][payload]` + optional zlib compression | Plugin, app |
| [pipe.md](pipe.md) | **Relayed byte-stream** (TURN-style): control-plane signaling + `/v1/pipe` data plane, per-pipe ephemeral DH (PFS), splice + limits | Relay, relay client, app |
| [payloads.md](payloads.md) | **E2E payload schemas** (the encrypted content the relay never sees) | Plugin, app |
| [describe-and-push.md](describe-and-push.md) | **Approval rendering**: `summary` + structured `blocks`, push delivery model | Plugin, app |
| [server.md](server.md) | **Relay server** implementation (Rust): zero-trust, store-and-forward, push bridge, deploy | Relay coding agent |
| [test-vectors.md](test-vectors.md) | **Crypto test vectors** + reference generator for byte-for-byte interop | All implementors |
> **Recommended reading order for a coding agent:** index → crypto → relay-protocol → framing →
> payloads → (your component's file) → test-vectors.
## 7. Versioning
- Protocol version in the URL: `/v1/ws`. Payload schema version in the `v` field (integer) of each
E2E JSON.
- Crypto domain constants (salt/info/prefix) contain `v1`. A future protocol would use different
constants: no cross-version confusion possible.
- **All** normative constants live in [crypto.md §1](crypto.md). No other file redefines them.
- The WebSocket transport uses **protobuf binary frames** (`RelayFrame`, package `skald.relay.v2`)
with raw bytes for all binary fields. The proto schema lives in `crates/skald-relay-common`.
- E2E plaintext framing is versioned by the `version` byte (`0x01` = JSON app payload, `0x02` = pipe
signaling), independently of the JSON payload schema version (`v` field). See [framing.md](framing.md).
- The pipe data plane adds **one** endpoint, `/v1/pipe` (relayed byte-stream). See [pipe.md](pipe.md).
## 8. Links
- Skald backend: `crates/` (workspace root)
- Shared crate: `crates/skald-relay-common/`
- Mobile connector plugin: `crates/plugin-mobile-connector/`
- Relay server: `crates/skald-relay-server/`
- iOS app: `/Users/dguiducci/projects/skald-ios/` (target `SkaldInbox` + Notification Service Extension)
- iOS skill: `skills/ios-development/SKILL.md`
+389
View File
@@ -0,0 +1,389 @@
# E2E Payloads — Encrypted Content Schemas
> This file defines the **plaintext** that is encrypted (AES-256-GCM, [crypto.md §6](crypto.md))
> and transported in the `ciphertext` field of the `Message` frame
> ([relay-protocol.md §6](relay-protocol.md)). **The relay never sees any of this.** Only the
> agent and the client see it.
>
> The plaintext is **JSON UTF-8**, wrapped in the framing envelope ([framing.md](framing.md))
> before encryption. No canonical form is required: it is encrypted as a byte blob and re-parsed
> by the recipient; it is never hashed separately.
---
## 1. Common Envelope
Every E2E payload has these base fields, plus kind-specific ones:
```json
{
"v": 1,
"kind": "<string>",
"id": "<uuid-v4>",
"ts": 1750000000000
}
```
| Field | Type | Required | Meaning |
|-------|------|----------|---------|
| `v` | int | yes | Payload schema version. `1` here. Different value → receiver discards with log. |
| `kind` | string | yes | Discriminant (table §2). |
| `id` | string (uuid-v4) | yes | Unique message id. Used for dedup at payload level and for acks. |
| `ts` | int (unix ms) | yes | Sender-side creation timestamp. Freshness check (§6). |
Common rules:
- **Forward-compat**: unknown fields are ignored. An unknown `kind` is discarded (with log),
not a fatal error.
- **Idempotency**: the receiver MUST handle every payload idempotently relative to its action
identifier (`request_id` for responses; `id` for generic dedup).
- **Anti-replay**: guaranteed by the nonce counter ([crypto.md §6.1](crypto.md)); `id`/`ts` are
additional application-level defences.
---
## 2. Kind Catalogue
| `kind` | Direction | Purpose |
|--------|-----------|---------|
| `inbox_update` | agent → client | Full Inbox snapshot (pending approvals + clarifications + elicitations). |
| `notification` | agent → client | Generic notification (title/body), for informational pushes. |
| `hello` | client → agent | First message after pairing: detailed `device_info`. |
| `inbox_request` | client → agent | Explicit Inbox snapshot request; agent responds with a **targeted** `inbox_update`. |
| `approval_response` | client → agent | Outcome of an approval request. |
| `clarification_response` | client → agent | Answer to a clarification. |
| `elicitation_response` | client → agent | Reply to an MCP elicitation (carries the requested value E2E). |
| `logout` | client → agent | Device removes itself from the namespace. |
| `ack` | bidirectional | Delivery confirmation (optional, for reliability). |
---
## 3. Agent → Client
### 3.1 `inbox_update` — Inbox Snapshot
**Full snapshot**, not a delta: contains **all** currently pending items. Idempotent by
construction (replaces local state). So a lost push does not cause state loss: the next snapshot
realigns.
```json
{
"v": 1,
"kind": "inbox_update",
"id": "0c5b…",
"ts": 1750000000000,
"badge": 2,
"approvals": [
{
"request_id": "appr_8f2a…",
"tool_name": "send_email",
"agent_label": "Skald",
"summary": "Send an email to mario@acme.com",
"detail": "Subject: Q3 Estimate\nBody: …",
"arguments": { "to": "mario@acme.com", "subject": "Q3 Estimate" },
"created_at": 1749999990000
}
],
"clarifications": [
{
"request_id": "clar_3b1c…",
"question": "Proceed with the €240 payment?",
"context": "Invoice #1234, supplier X",
"suggested_answers": ["Yes, proceed", "No, cancel"],
"agent_label": "Skald",
"created_at": 1749999991000
}
],
"elicitations": [
{
"request_id": "elic_5d7e…",
"server_name": "ssh",
"message": "Enter the SSH password for deploy@host",
"field_name": "password",
"sensitive": true,
"is_confirmation": false,
"created_at": 1749999992000
}
]
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `badge` | int | yes | Total pending item count (= len(approvals)+len(clarifications)+len(elicitations)). Used by the client for badge. |
| `approvals[]` | array | yes | May be empty. |
| `approvals[].request_id` | string | yes | **Action identifier.** Stable while the item is pending. Used for response idempotency. |
| `approvals[].tool_name` | string | yes | Name of the tool requesting approval (e.g. `send_email`, `execute_cmd`). |
| `approvals[].agent_label` | string | yes | Human-readable origin label (typically `"Skald"`). |
| `approvals[].summary` | string | yes | Short line for notification/card (≤ ~120 chars). |
| `approvals[].detail` | string | no | Extended text for the detail screen. |
| `approvals[].arguments` | object | no | **Raw tool arguments** (JSON passed by the LLM). Source of truth for the detail screen: the client shows these so the user knows *what* they are approving (critical for `execute_cmd` → show `arguments.command`). May be absent for tools without arguments. E2E encrypted along with the rest of the payload. |
| `approvals[].created_at` | int (unix ms) | yes | When the request was created on the Skald side. |
| `clarifications[]` | array | yes | May be empty. |
| `clarifications[].request_id` | string | yes | Action identifier. |
| `clarifications[].question` | string | yes | Question to display. |
| `clarifications[].context` | string | no | Optional context. |
| `clarifications[].suggested_answers` | array of strings | no | Pre-defined answers suggested by the LLM. May be empty/absent. The client shows them as quick-tap options; free-form input is always possible too. The choice is sent as `clarification_response.answer` (§4.3). |
| `clarifications[].agent_label` | string | yes | Origin label. |
| `clarifications[].created_at` | int (unix ms) | yes | — |
| `elicitations[]` | array | yes | May be empty. MCP server-initiated input requests (e.g. an SSH/sudo password). |
| `elicitations[].request_id` | string | yes | Action identifier. Echoed back in `elicitation_response` (§4.4). |
| `elicitations[].server_name` | string | yes | MCP server that asked for input (e.g. `"ssh"`). |
| `elicitations[].message` | string | yes | Prompt to display to the user. |
| `elicitations[].field_name` | string \| null | no | Key the requested value must be stored under in `elicitation_response.content`. `null` for a bare confirmation. |
| `elicitations[].sensitive` | bool | yes | When `true`, the value is a secret: the client SHOULD mask input and MUST NOT cache/persist it. |
| `elicitations[].is_confirmation` | bool | yes | When `true`, this is a yes/no confirmation (no value field); `accept`/`decline` suffice and `content` is omitted. |
| `elicitations[].created_at` | int (unix ms) | yes | — |
> **Elicitation values never appear here.** This snapshot carries only the *prompt* metadata. The
> value the user supplies travels **only** in the client→agent `elicitation_response.content`
> (§4.4) and is handed straight to the MCP server; the agent never logs or persists it in clear.
>
> **Push privacy.** When this snapshot is sent to an offline client, the relay delivers it
> (encrypted) in the push *content-in-push* if it fits the APNs/FCM limit. Keep `summary`/`detail`
> short. If it exceeds the limit, the relay sends a *wake* and the client downloads the snapshot
> over WS ([server.md §5](server.md)).
### 3.2 `notification` — Generic Notification
```json
{ "v":1, "kind":"notification", "id":"…", "ts":, "title":"Skald", "body":"Nightly job completed" }
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `title` | string | yes | Notification title. |
| `body` | string | yes | Notification body. |
No response required. Does not affect the badge unless accompanied by an `inbox_update`.
### 3.3 `ack` (optional)
```json
{ "v":1, "kind":"ack", "id":"…", "ts":, "ref_id":"<id of confirmed payload>" }
```
Confirms that a payload with `id == ref_id` was received/processed. Optional (store-and-forward
+ idempotent snapshots suffice for v1).
---
## 4. Client → Agent
### 4.1 `hello` — Post-Pairing Application Handshake
First E2E message the client sends after it is authorised and connected as `client`. Transfers
detailed `device_info` **outside the relay's view**.
```json
{
"v": 1,
"kind": "hello",
"id": "…",
"ts": ,
"device_info": {
"platform": "ios",
"model": "iPhone 16 Pro",
"os_version": "18.5",
"app_version": "1.0.0",
"device_name": "Daniele's iPhone"
}
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `device_info.platform` | string | yes | `"ios"` \| `"android"`. |
| `device_info.model` | string | no | Hardware model. |
| `device_info.os_version` | string | no | OS version. |
| `device_info.app_version` | string | no | App version. |
| `device_info.device_name` | string | no | Human-readable name for the agent's device list UI. |
The agent persists this data and shows it in the device list.
### 4.2 `approval_response` — Approval Outcome
```json
{
"v": 1,
"kind": "approval_response",
"id": "…",
"ts": ,
"request_id": "appr_8f2a…",
"decision": "approved",
"reason": null,
"bypass_secs": 900
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `request_id` | string | yes | MUST match an `approvals[].request_id` received. |
| `decision` | enum string | yes | **Only** `"approved"` \| `"rejected"`. Other values → agent discards. |
| `reason` | string \| null | no | Reason (typically for `rejected`). |
| `bypass_secs` | int | no | With `decision="approved"` only. Approve **and** register a bypass for similar tools: `900` = 15 minutes, `0` = for the entire session. **Absent** = single approval (current behaviour). The scope (tool category / MCP server / all) is auto-detected by the agent: the client only sends the seconds. |
Agent behaviour (see [../plugins/mobile-connector.md](../plugins/mobile-connector.md)):
1. Resolves the request via Skald's Inbox/ApprovalManager (`resolve(request_id, decision, reason)`).
If `decision="approved"` and `bypass_secs` is present, uses `approve_with_bypass` instead
of simple approve (registers the session bypass with auto-detected scope).
2. **Idempotency**: if `request_id` is already resolved (or no longer pending), the operation
is a **no-op** (log and ignore). Neutralises replays and double deliveries.
3. Sends a new `inbox_update` (the snapshot will no longer contain that item) to realign clients.
### 4.3 `clarification_response` — Clarification Answer
```json
{
"v": 1, "kind": "clarification_response", "id": "…", "ts": ,
"request_id": "clar_3b1c…",
"answer": "Yes, proceed."
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `request_id` | string | yes | MUST match a `clarifications[].request_id`. |
| `answer` | string | yes | Free-form answer text. |
Same `request_id` idempotency as §4.2.
### 4.4 `elicitation_response` — MCP Elicitation Reply
Reply to an `elicitations[]` entry (§3.1): an MCP server asked for an input the LLM must not see
(e.g. an SSH/sudo password). The requested value travels **only** in this payload's `content`,
sealed E2E — the relay never sees it and the agent hands it straight to the MCP server without
logging or persisting it.
```json
{
"v": 1, "kind": "elicitation_response", "id": "…", "ts": ,
"request_id": "elic_5d7e…",
"action": "accept",
"content": { "password": "hunter2" }
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `request_id` | string | yes | MUST match an `elicitations[].request_id`. |
| `action` | enum string | yes | **Only** `"accept"` \| `"decline"` \| `"cancel"`. Other values → agent discards. `decline` rejects the prompt; `cancel` aborts the whole request. |
| `content` | object \| null | conditional | Present **only** with `action="accept"` for a value prompt: a single key equal to the elicitation's `field_name`, whose value is the user's input (possibly a secret). Absent/null for `decline`/`cancel` and for confirmations (`is_confirmation=true`). A non-object `content` is dropped. |
Agent behaviour:
1. Resolves the request via Skald's Inbox (`resolve_elicitation(request_id, action, content)`), which
forwards the outcome to the `ElicitationManager` and unblocks the waiting MCP call.
2. **Idempotency**: a `request_id` already resolved (or no longer pending) is a **no-op**.
3. Sends a new `inbox_update` to realign clients.
> **Secret hygiene.** `content` may carry a secret. It is never written to logs, the DB, or any
> trace on the agent side; it lives only long enough to satisfy the MCP `elicitation/create` call.
### 4.5 `logout` — Device Self-Removal
```json
{ "v":1, "kind":"logout", "id":"…", "ts": }
```
The agent, on receipt:
1. removes `client_ed25519_pub` from the local authorised list;
2. sends an updated `Authorize` (without that client) to the relay → the relay closes the
device's WS, purges its queue, and forgets its `device_token`
([relay-protocol.md §5](relay-protocol.md));
3. forgets the client's keys/counters.
> Revocation can also be initiated **by the agent** (lost/stolen device): the user removes it via
> the Skald UI and the agent sends `Authorize` without that device. `logout` E2E is only the
> "device-initiated" case.
### 4.6 `inbox_request` — Explicit Inbox Snapshot Request
The client sends this payload to ask the agent for the current Inbox state.
**MUST be sent after `AuthOk` on every WS (re)connection** (including app open from a push),
because the agent does **not** receive a reconnect signal from the relay: without `inbox_request`
the client's Inbox would stay empty until a new bus event triggers a broadcast.
```json
{ "v":1, "kind":"inbox_request", "id":"…", "ts": }
```
No specific fields beyond the common envelope (§1).
Agent behaviour:
1. Builds the current Inbox snapshot (`list_pending()`).
2. Sends an `inbox_update` (§3.1) **targeted to the requester only** (not a broadcast): the
message is sealed with the requesting client's `aes_key`, leaving other devices unaffected.
3. Idempotent and side-effect-free on the Inbox: safe to send on every connection. If there are
no pending items, the snapshot has `badge:0` and empty arrays.
> This follows the *targeted request → targeted response* pattern. The payload travels on the
> **live channel** (`Message.live=true`, [relay-protocol.md §6.4](relay-protocol.md)): a stale
> Inbox snapshot is useless, so route-or-fail is correct — if the agent is offline, the client
> learns immediately via `PeerOffline`.
### 4.7 `ack` (optional)
Same as §3.3, opposite direction.
---
## 5. Inbox State Machine (client side)
```
inbox_update (snapshot)
┌──────────────────────────────────────┐
▼ │
[ local list ] ──user approves/rejects──▶ send approval_response
▲ │ (optimistic: remove card)
│ ▼
└──────────── next inbox_update ◀─── agent resolves and re-snapshots
```
- The client updates the UI **optimistically** (removes the card on response send), but the
**source of truth** is the next `inbox_update`. If the response is lost, the item reappears
on the next snapshot.
- Local `badge` = `badge` of the last snapshot, minus items already responded to locally
(reconciled on next snapshot).
## 6. Freshness & Validation (receiver side)
For every decrypted E2E payload, the receiver MUST:
1. verify the nonce **counter** (`> last_seen`, [crypto.md §6.1](crypto.md)) → otherwise discard;
2. verify `v == 1` → otherwise discard with log;
3. (SHOULD) discard if `|now - ts|` > 7 days (aligned with the queue TTL): extra defence against
very late replays;
4. validate required fields and types; a malformed payload is discarded without crash;
5. apply the action **idempotently** by `request_id` (responses) or `id` (generic dedup).
## 7. Complete Round-Trip Examples
**Approval (foreground):**
```
agent → inbox_update { approvals:[{request_id:"appr_1", tool_name:"send_email", …}], badge:1 }
client → approval_response { request_id:"appr_1", decision:"approved" }
agent → inbox_update { approvals:[], badge:0 } // realign
```
**Clarification (background, via content-in-push):**
```
agent → inbox_update { clarifications:[{request_id:"clar_9", question:"Proceed?"}], badge:1 }
(relay: client offline → push with encrypted blob)
client → (NSE decrypts, shows notification) → user opens app → clarification_response { request_id:"clar_9", answer:"Yes" }
agent → inbox_update { clarifications:[], badge:0 }
```
**MCP elicitation (SSH password, secret E2E):**
```
(MCP `ssh` server calls elicitation/create → agent blocks the tool call)
agent → inbox_update { elicitations:[{request_id:"elic_5", server_name:"ssh", field_name:"password", sensitive:true}], badge:1 }
client → (masked input) → elicitation_response { request_id:"elic_5", action:"accept", content:{ "password":"hunter2" } }
agent → (hands content to the MCP server, unblocks the call; value never logged) → inbox_update { elicitations:[], badge:0 }
```
**App opened from notification (reconnect):**
```
client → (connects as role:"client", auth_ok)
client → inbox_request { } // live channel (Message.live=true)
agent → inbox_update { approvals:[…], clarifications:[…], badge:N } // targeted to requester only
```
+215
View File
@@ -0,0 +1,215 @@
# Pipe — Relayed Byte-Stream over Skald Relay
> **Implementation reference.** A generic, content-blind, end-to-end-encrypted **byte-stream**
> channel between two members of a namespace, **relayed** (TURN-style) through the Skald relay. It
> sits ON TOP of the existing transport: signaling rides the existing E2E `Message` channel (no new
> `RelayFrame`); the data plane is **one new relay endpoint** (`/v1/pipe`). The relay splices opaque
> ciphertext and never reads it.
>
> **Status (v1, implemented).** Scope = **client↔agent** (the shared E2E key already exists, so the
> ephemeral handshake is authenticated by the channel that carries it). Suite = `x25519-sealed`.
> Compression = `none` (negotiation present for forward-compat). client↔client is deferred (needs a
> signed roster/manifest + a self-authenticating suite — see §7).
>
> Read after: `index.md` → `relay-protocol.md` → `crypto.md` → `framing.md`.
## 1. Why
The message channel (`relay-protocol.md`) is for **discrete** E2E payloads (approvals, clarifications,
health sync): ≤60 msg/min, ≤512 KiB/frame, store-and-forward. It serves **stream-shaped, high-volume**
flows poorly — log tailing, file transfer, audio, remote shell, real-time sensors. The pipe is the
**reusable streaming primitive** for those. It is **TURN's relayed mode**: a control plane brokers a
rendezvous; a separate connection carries a raw encrypted byte stream the relay blindly splices, so
TCP/WS gives reliability/ordering/flow-control for free (no reinvented windowing).
```
Control plane (existing E2E Message channel) Data plane (new WSS /v1/pipe)
A ──pipe_invite (live)──▶ R ──▶ B A ──▶ R ◀── B (each dials out; NAT-friendly)
A ◀──pipe_accept (live)── R ◀── B R verifies auth, matches by connection_id,
(ephemeral X25519 exchanged → per-pipe key, PFS) then splices opaque ciphertext frames
B offline ⇒ A gets PeerOffline ⇒ abort A ⇄ B: AES-256-GCM stream, relay sees ciphertext
```
## 2. Control plane — signaling
Pipe signaling rides the **existing** `Message{live=true}` E2E frame. It is **not** a new
`RelayFrame`; the relay stays content-blind. To distinguish it from JSON app payloads on the same
channel, the decrypted plaintext uses a reserved framing header (`crypto.md §1`):
```
FRAMING_VERSION_PIPE (0x02) ‖ COMP_NONE (0x00) ‖ <MsgPack PipeSignal>
```
The receiver peeks the first byte (`crypto::is_pipe_signal`): `0x02` ⇒ route to the pipe layer;
`0x01` ⇒ the existing JSON app path, unchanged. `live=true` is required — a stale "please connect" is
useless; if the peer is offline the initiator gets `PeerOffline` (`relay-protocol.md §6.4`) and aborts.
**Wire format = MsgPack** (`rmp-serde`, named maps). `PipeSignal` is externally tagged
(`{ "Invite": {…} }`) so a blob is self-describing. Byte fields are length-validated on decode.
| Message | Fields |
|---------|--------|
| `Invite` | `connection_id` (32B), `suite`, `handshake` (opaque; initiator ephemeral X25519 pub for `x25519-sealed`), `stream_type` (app-defined), `compress` (advertised list), `headers` (arbitrary `String→String`) |
| `Accept` | `connection_id`, `suite`, `handshake` (responder ephemeral pub), `compress` (selected codec) |
| `Reject` | `connection_id`, `reason` |
- **`connection_id`**: 32 random bytes, single-use, short-lived. The rendezvous key, known only to A
and B (sent E2E). **Not** a security boundary on its own — the data-plane signature (§3) is.
- **`suite`** is a discriminator and **`handshake` is opaque**: adding a Noise suite (§7) is a new
variant with the **same wire shape**. Signaling is **symmetric** (initiator/responder by role,
never agent-vs-client) so client↔client is not blocked.
- **`headers`**: app metadata for the stream (filename/size for a transfer, filters for a log tail).
By `pipe_accept` both sides have the peer's ephemeral pubkey and derive the per-pipe key (§4).
## 3. Data plane — `WSS /v1/pipe`
A **second WebSocket**, separate from the control WS, binary frames carrying **raw bytes** (no
protobuf). Chosen over HTTP `CONNECT` / raw TCP for reachability: 443/TLS, traverses CDN / L7 LB /
mobile carriers, camouflaged as a normal WS. The socket **is** the tunnel (one connection per pipe);
the control WS stays separate and alive.
### 3.1 Auth handshake (relay-mediated, MsgPack)
Mirrors the main WS "relay speaks first":
```
A → WSS /v1/pipe
R → PipeChallenge { nonce: 32B } (relay speaks first)
A → PipeAuth {
connection_id, pubkey (ed25519, 32B),
dest = SHA256(peer_ed25519_pub) (32B), (declares intended counterparty)
namespace_id (raw 32B),
signature = sign_ed25519(priv, PIPE_AUTH_DOMAIN ‖ 0x00 ‖ nonce ‖ connection_id) (64B)
}
R verifies, in order:
1. signature valid under pubkey (verify_strict) → else close
2. pubkey is the agent of namespace_id, OR an authorized client → else close
3. (on the second side) cross-refs match (§3.2) → else close both
```
The reply is a **signature**, not an echo — it proves control of `pubkey`, exactly like the main WS
auth. `connection_id` is **not** trusted as identity.
### 3.2 Matching & splice (relay state machine)
```
challenge → pipe_auth → pending → matched → streaming → teardown
```
- **pending**: first authenticated side for `connection_id` is parked (TTL); the namespace pipe count
is incremented.
- **matched**: second side authenticates → relay verifies the cross-refs
`SHA256(A.pubkey)==B.dest AND SHA256(B.pubkey)==A.dest AND same namespace`, then hands the second
side's socket halves to the first.
- **streaming**: the first side owns a bidirectional forward loop of binary-frame payloads. The relay
reads nothing else; WS-level pings are answered on the originating leg; data is rate-limited per
direction.
- **teardown**: either side closing/erroring tears down both (no orphans). *(v1 closes both; FIN
half-close propagation is a future refinement.)*
### 3.3 Relay limits (NORMATIVE; env-overridable)
The relay becomes a **stateful connection proxy** (TURN resource model): fd+buffers per pipe, idle
reaping, pending TTL, per-namespace concurrency cap, backpressure (no unbounded buffering).
| Limit | Env var | Default | Why |
|-------|---------|---------|-----|
| Pending half-open TTL | `RELAY_PIPE_PENDING_TTL_SECS` | 30 s | A dialed, B never showed → reap. |
| Idle pipe timeout | `RELAY_PIPE_IDLE_TIMEOUT_SECS` | 120 s | Reclaim dead pipes. |
| Max concurrent pipes / namespace | `RELAY_PIPE_MAX_PER_NS` | 8 | Bound proxy resource use. |
| Max data-plane frame | `RELAY_PIPE_MAX_FRAME_BYTES` | 1 MiB | Bulk transfer; separate from the message-channel quota. |
| Bandwidth cap (per connection, per direction) | `RELAY_PIPE_MAX_BPS` | 0 (unlimited) | Token bucket; stops the pipe being a free unmetered tunnel. |
## 4. Secure channel — reused AES-256-GCM, ephemeral DH (PFS)
The A↔B stream reuses the **existing** crypto primitives (`crypto.md`), not Noise/TLS — the same
AES-256-GCM / X25519 / HKDF stack already interop-tested against the iOS client:
- **Per-pipe key**: each side samples a fresh ephemeral X25519, exchanges the pubkey in the signaling,
and computes `pipe_key = HKDF(ECDH(eph), salt=PIPE_KDF_SALT, info=PIPE_KDF_INFO)`. Ephemeral DH ⇒
**Perfect Forward Secrecy** per pipe (closes the gap in `index.md §4.3` for this channel).
- **Authentication**: the ephemeral pubkeys travel **inside the E2E-sealed signaling**, so for
client↔agent they are authenticated by the existing channel — no signatures needed in the
handshake (that is the `x25519-sealed` suite). client↔client (no pre-shared key) needs a
self-authenticating suite (§7).
- **Frame crypto**: each chunk is `AES-256-GCM(pipe_key, nonce, aad)`. The 12-byte nonce is
`DIR (4B) ‖ counter (8B)` with a per-direction counter (`DIR_PIPE_INITIATOR` / `DIR_PIPE_RESPONDER`),
**not transmitted** (reconstructed by the receiver — strict in-order WS/TCP delivery). `aad =
connection_id` (binds frames to the rendezvous). Counters start at 1.
The relay never holds `pipe_key`; mismatched keys fail the GCM tag (confidentiality holds even if the
relay mis-splices).
## 5. Compression
Negotiated in the handshake (`compress` advertise→select), per direction, `none | zlib`. **v1 ships
`none` only** — the negotiation field exists for forward-compat. Stateful streaming `zlib` is deferred
(it is a shared-dictionary deflate context, and on a *generic* bus it reintroduces the CRIME/BREACH
class for `stream_type`s mixing attacker-controlled plaintext with secrets).
## 6. Library API (`skald-relay-client`)
```
RelayClient::open_pipe(peer, stream_type, headers) -> PipeConnection // initiator
RelayClient::incoming_pipes() -> broadcast::Receiver<IncomingPipe> // responder feed
RelayClient::accept_pipe(&IncomingPipe) -> PipeConnection // responder
RelayClient::reject_pipe(&IncomingPipe, reason)
PipeConnection::{ send(&[u8]), recv() -> Option<Vec<u8>>, close() } // sealed/opened transparently
PipeConnection::split() -> (PipeSender, PipeReceiver) // independent halves
PipeSender::send(&[u8]) // seals + queues; blocks only when the send buffer is full
PipeReceiver::recv() -> Option<Vec<u8>>
```
Inbound pipe invites surface on a **separate channel** (`incoming_pipes`), **not** as a `RelayEvent`
variant — so adding the pipe is purely additive and the `plugin-mobile-connector` consumer compiles
unchanged. The relay client owns the pipe control plane end-to-end (it intercepts only the `pipe_*`
signaling kinds; every other payload stays pass-through).
### 6.1 Full-duplex & client-side backpressure
A `PipeConnection` is **full-duplex**: on `connect` the data-plane socket is split into a sink + stream
and a background **writer task** takes the sink. `send` seals the chunk and hands it to the writer
(returning before the flush); `recv` reads the stream directly. So a slow flush on one direction never
stalls the other — and `split() -> (PipeSender, PipeReceiver)` lets each direction run in its own task
with no shared `&mut`. The per-direction counter nonce stays ordered because `send` is single-writer
(`&mut self`) and the writer drains FIFO.
**Backpressure** is an in-memory byte budget (`SEND_BUFFER_BYTES`, ~10 MiB) held as a semaphore: `send`
reserves the sealed frame's bytes and the writer releases them *after* the frame is flushed. While the
socket drains normally `send` returns immediately; if the peer/relay stops draining, the budget empties
and `send` **blocks** until space frees up (bounding memory). This sits under the relay's own per-pipe
limits (§3.3). WS-level pings stay responsive: the read half forwards `Pong`s to the writer on a
separate control channel that the budget never throttles. Dropping both halves (or `close()`) tears the
pipe down — the writer closes the socket once its channels are gone.
### 6.2 Agent-side consumers (by `stream_type`)
- **`http-local-proxy`** — `plugin-mobile-connector` (`src/proxy.rs`) accepts these pipes and
reverse-proxies each, byte-for-byte, to the local web server at `127.0.0.1:<web_port>`, letting a
native WebView render the Skald web UI over the relay (no NAT hole / Tailscale). It `split`s each
pipe and runs the two directions in **separate tasks** (remote→local writes to the web server;
local→remote reads it and `send`s back, backpressured by the send buffer), so a stalled direction
can't block the other. Destination is pinned (not client-chosen); access is already gated by §3.1
(agent or authorized client). See
[../plugins/mobile-connector.md](../plugins/mobile-connector.md#http-reverse-proxy-http-local-proxy).
## 7. client↔client (deferred)
The data plane is **already** client↔client-capable: the relay authenticates by namespace membership +
cross-dest, not by agent-vs-client. Two things are missing above it, both additive:
1. **Key/identity distribution** — clients don't know each other's keys. Plan: the agent signs a
**manifest** (versioned roster of authorized members' pubkeys) the relay caches and serves; clients
verify the agent's ed25519 signature.
2. **Self-authenticating handshake** — without a pre-shared client↔client key the ephemeral exchange
can't be sealed in an existing channel. Plan: a new `suite` (e.g. `noise-nn+ed25519`) — same
signaling wire shape, new `handshake` interpretation. The relay does **not** change.
## 8. Verification (implemented)
- **relay-common** (`crypto.rs`, `pipe.rs` tests): MsgPack round-trips; `pipe_auth` sign/verify binds
nonce + connection_id (and rejects an `AUTH_DOMAIN` signature — domain separation); pipe-key
symmetry over ephemeral DH; pipe-signal framing peek.
- **relay-server** (`tests/pipe.rs`): two raw WS peers → `pending→matched→streaming→teardown`;
rejects bad signature, cross-dest mismatch, non-member; teardown closes the peer.
- **relay-client** (`src/pipe.rs` net tests): two `PipeConnection`s stream bytes (incl. a 200 KiB
blob) both ways through the **real** relay; a wrong key fails AEAD open (relay never had plaintext);
`split` both ends and stream 1 MiB **each way simultaneously** (full-duplex — neither direction
blocks the other). Signaling routing (`src/state.rs`): invite → `incoming_pipes`, accept/reject → the
waiter.
- **Non-regression**: `plugin-mobile-connector` builds unchanged; existing `protocol.rs` /
`integration.rs` suites still pass.
## 9. Out of scope (deferred)
- Stateful `zlib` compression (negotiation reserved).
- HTTP/2 extended `CONNECT` multiplexing (many pipes over one socket).
- client↔client (§7) and the specific app `stream_type` consumers.
+521
View File
@@ -0,0 +1,521 @@
# Relay Protocol — WebSocket
> Transport protocol between **any actor** (agent, client, pairing) and the **relay**, over
> **a single WebSocket**. No REST. This file defines the **protobuf frame schema**, the
> **authentication handshake**, the **E2E message envelope**, the **live channel**, **presence**,
> and the **pairing flow**. The **encrypted content** inside the envelope is in
> [payloads.md](payloads.md); the **cryptography** is in [crypto.md](crypto.md).
>
> MUST/SHOULD carry the RFC 2119 meaning.
**Transport**: every WebSocket frame is a **binary frame** (opcode `0x2`) carrying exactly one
`RelayFrame` protobuf. All binary fields (keys, signatures, nonces, namespace_id) travel as
**raw bytes** — no hex, no base64. The encoding rules in [index.md §5](index.md) apply only
inside the E2E JSON payloads, not to the transport layer.
---
## 1. Concepts
- **Namespace**: created implicitly when an `agent` authenticates for the first time. Identified by
`namespace_id = hex(SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub))` ([crypto.md §7](crypto.md)).
Expires after **7 days** without any connection.
- **Owner**: the `agent` holding the namespace private key. **Sole authority** over the authorised
client list.
- **Client**: a mobile device **authorised by the agent**. Before pairing it does not exist; after
pairing it is `pending` until the agent authorises it.
## 2. Endpoint
```
wss://<relay-host>/v1/ws
```
Single endpoint for all actors. The **role** is established in the `Auth` frame. `namespace_id`
is NOT in the query string: it travels inside `Auth`. Transport: **WSS mandatory** (TLS); the
relay MUST reject plain WS.
## 3. RelayFrame Schema (NORMATIVE)
Lives in `crates/skald-relay-common`, generated for Rust (`prost`) and iOS (`SwiftProtobuf`).
Package name: `skald.relay.v2`.
```proto
syntax = "proto3";
package skald.relay.v2;
// One WebSocket binary frame = one RelayFrame.
message RelayFrame {
oneof frame {
Challenge challenge = 1;
Auth auth = 2;
AuthOk auth_ok = 3;
AuthError auth_error = 4;
Authorize authorize = 5;
AuthorizeOk authorize_ok = 6;
PairingStart pairing_start = 7;
PairingReady pairing_ready = 8;
PairingStop pairing_stop = 9;
PairingStopOk pairing_stop_ok = 10;
ClientPaired client_paired = 11;
Message message = 12;
PeerOffline peer_offline = 13;
PresenceRequest presence_request = 14;
PresenceList presence_list = 15;
PresenceEvent presence_event = 16;
Error error = 17;
}
reserved 18, 19; // ex Ping/Pong: keepalive via native WS frames (§8), not protobuf
}
// --- Data plane (E2E). The relay routes, does NOT read ciphertext/nonce. ---
message Message {
bytes ciphertext = 1; // E2E payload: JSON+framing (framing.md). Opaque to relay.
bytes nonce = 2; // 12B, AEAD nonce
bytes peer = 3; // 32B: 'to' on send (sender→relay), 'from' on delivery (relay→dest)
bool live = 4; // true = live channel (§6): route-or-fail, no queue/push
}
message PeerOffline { bytes peer = 1; } // 32B: recipient not connected (live channel only)
// --- Presence (§7). Control frames, not E2E. ---
message PresenceRequest {}
message PresenceList { repeated bytes online = 1; } // 32B each
message PresenceEvent { bytes pubkey = 1; Status status = 2; } // 32B
enum Status { STATUS_UNSPECIFIED = 0; STATUS_ONLINE = 1; STATUS_OFFLINE = 2; }
// --- Handshake / auth / pairing: role is implicit in the sub-message set.
// No enum Role (its default 0 would mean "AGENT" — security footgun). ---
message Challenge { bytes nonce = 1; } // 32B
message Auth {
oneof role {
AuthAgent agent = 1;
AuthClient client = 2;
AuthPairing pairing = 3;
}
bytes signature = 4; // 64B, over AUTH_DOMAIN‖0x00‖nonce
}
message AuthAgent { bytes agent_ed25519_pub = 1; } // 32B; namespace_id = hash(pubkey)
message AuthClient {
bytes namespace_id = 1; // 32B
bytes client_ed25519_pub = 2; // 32B
string device_token = 3; // push token (opaque)
Platform platform = 4;
}
message AuthPairing {
bytes namespace_id = 1; // 32B
bytes client_ed25519_pub = 2; // 32B
bytes client_x25519_pub = 3; // 32B
bytes pairing_token = 4; // 32B
string device_token = 5; // push token (opaque)
Platform platform = 6;
}
enum Platform { PLATFORM_UNSPECIFIED = 0; PLATFORM_IOS = 1; PLATFORM_ANDROID = 2; }
message AuthOk { bytes namespace_id = 1; }
message AuthError { string code = 1; string message = 2; }
message Authorize { repeated bytes clients = 1; } // 32B each (replaces full list)
message AuthorizeOk { uint32 authorized = 1; }
message PairingStart { bytes pairing_token = 1; uint32 ttl = 2; }
message PairingReady { uint32 ttl = 1; }
message PairingStop {}
message PairingStopOk {}
message ClientPaired {
bytes client_ed25519_pub = 1;
bytes client_x25519_pub = 2;
Platform platform = 3;
}
message Error { string code = 1; string message = 2; }
// Keepalive: native WebSocket ping/pong frames (§8), not protobuf messages.
```
> **Validation (proto3 has no `required`).** The role split prevents cross-role confusion but
> does not enforce non-empty fields: the relay MUST still validate the **presence and length** of
> `bytes` fields (32B pubkeys, 64B signatures, …) and reject with `bad_request`. The
> `*_UNSPECIFIED = 0` enum values make "absent enum field" distinguishable and rejectable.
## 4. Authentication Handshake
**The relay speaks first.** As soon as the WS is open, it sends a `Challenge`. Until `AuthOk`
arrives, the only frame accepted from the peer is `Auth`.
```
PEER (agent | pairing | client) RELAY
│ ── WSS connect ───────────────────────────── ▶│
│ ◀──── Challenge { nonce: 32B } ───────────────│ relay speaks first
│ ── Auth { role:..., signature: 64B } ────────▶│
│ ◀──── AuthOk { namespace_id } ────────────────│
│ OR AuthError { code, message } ────────────│
```
- `Challenge.nonce`: 32 random bytes. Unique per connection. Expires after **30 s**: no `Auth`
in time → `challenge_timeout` and close.
- `Auth.signature`: Ed25519 signature of `AUTH_DOMAIN ‖ 0x00 ‖ nonce_raw` ([crypto.md §8](crypto.md)).
- The relay MUST verify the signature under the role-appropriate public key **before** any other
logic.
### 4.1 `role: agent` — the Skald instance
The namespace may not exist yet: it is created here.
```proto
Auth {
agent: AuthAgent { agent_ed25519_pub: <32B> },
signature: <64B>
}
```
Relay checks (in order):
1. `agent_ed25519_pub` is exactly 32 bytes.
2. `signature` valid over `AUTH_DOMAIN ‖ 0x00 ‖ nonce_raw` under `agent_ed25519_pub`.
3. Compute `namespace_id = SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub)`.
4. If namespace doesn't exist → create it (bind `namespace_id ↔ agent_ed25519_pub`, immutable).
If it exists → pubkey MUST match (by construction it does, since the id is a hash of the key;
a mismatch is a bug → `not_found`).
5. If an `agent` WS is already open for this namespace → close the old one (one agent connection
per namespace at a time).
Response: `AuthOk { namespace_id: <32B raw> }`.
Right after, the agent SHOULD send an `Authorize` frame (§5) with the current authorised client
list (possibly empty).
### 4.2 `role: pairing` — not-yet-authorised client
For initial connection before authorisation. Accepted only if the namespace is in **pairing mode** (§9).
```proto
Auth {
pairing: AuthPairing {
namespace_id: <32B>,
client_ed25519_pub: <32B>,
client_x25519_pub: <32B>,
pairing_token: <32B>,
device_token: "<push token>",
platform: PLATFORM_IOS | PLATFORM_ANDROID
},
signature: <64B>
}
```
Relay checks:
1. `signature` valid under `client_ed25519_pub`.
2. `namespace_id` exists and is in pairing mode.
3. `pairing_token` matches **byte-for-byte** the one from `PairingStart`, **not expired**,
**not yet consumed** (single-use).
4. Mark the token **consumed**. Register the client as **`pending`** (NOT yet authorised):
store `client_ed25519_pub`, `client_x25519_pub` (opaque), `device_token`, `platform`.
5. Forward a `ClientPaired` frame to the agent (§9.4).
Response: `AuthOk { namespace_id: <32B raw> }`.
After `AuthOk` the pairing client **closes** the WS. It becomes operational by reconnecting with
`role: client` **once the agent has authorised it** (the app may retry with backoff until it
receives `AuthOk` instead of `unauthorized`).
> `device_token` and `platform` are the **only** device data the relay knows: required for push.
> Model, OS, app version do NOT pass through the relay: the app sends them **E2E** to the agent
> via a `hello` message ([payloads.md](payloads.md)).
### 4.3 `role: client` — authorised device
```proto
Auth {
client: AuthClient {
namespace_id: <32B>,
client_ed25519_pub: <32B>,
device_token: "<push token>",
platform: PLATFORM_IOS | PLATFORM_ANDROID
},
signature: <64B>
}
```
Relay checks:
1. `signature` valid under `client_ed25519_pub`.
2. `namespace_id` exists.
3. `client_ed25519_pub` is in the **authorised** list (NOT `pending`). Otherwise `unauthorized`.
4. Update `device_token` (it can change: APNs/FCM rotate it).
5. If a `client` WS is already open for the same pubkey → close the old one.
6. Deliver any queued messages (store-and-forward, §6.3).
Response: `AuthOk { namespace_id: <32B raw> }`.
## 5. Client Authorisation (agent only)
The agent is the **sole authority**. The authorised list is declared with:
```proto
Authorize { clients: [ <32B>, <32B>, ] }
```
- **Replacement semantics**: this list **replaces** the previous one (not an append). To add a
device, send the full list including it; to revoke one, send the list without it.
- Relay effects, atomic:
- keys present now but absent before → become `authorised` (exit `pending`);
- keys absent now but present before → **revoked**: the relay MUST (a) close that client's active
WS if any, (b) **purge its store-and-forward queue**, (c) forget its `device_token`.
- Response: `AuthorizeOk { authorized: N }` (N = number of active authorised clients).
## 6. E2E Messages
After `AuthOk`, agent and client exchange **opaque** messages routed by pubkey.
### 6.1 Sending (sender → relay)
```proto
Message {
ciphertext: <bytes>, // E2E blob: framed JSON, encrypted AES-256-GCM
nonce: <12B>,
peer: <32B>, // destination ed25519 pubkey
live: false // or true for live channel (§6.4)
}
```
- `peer` = ed25519 pubkey of the recipient (the agent, or a client).
- The relay knows the sender: it is the pubkey authenticated on **this** WS. It does NOT trust any
`from` field supplied by the sender.
- The relay MUST verify that `peer` belongs to the **same namespace** (namespace agent, or an
authorised client). Otherwise `not_found`.
- The relay NEVER reads or alters `nonce` and `ciphertext`.
### 6.2 Receiving (relay → recipient)
The relay rewrites `Message.peer` from `to` (the destination sent by the sender) to `from`
(the authenticated pubkey of the sender, which the relay guarantees), and adds a routing
`timestamp` (advisory, ISO-8601 UTC) via delivery metadata if needed.
```proto
Message {
ciphertext: <bytes>,
nonce: <12B>,
peer: <32B>, // 'from': authenticated sender pubkey
live: <bool>
}
```
The recipient:
1. reconstructs the AAD = `namespace_id_raw ‖ peer_pub(from) ‖ my_pub` ([crypto.md §6.2](crypto.md));
2. selects the `aes_key` for peer `from`;
3. decrypts; verifies the **counter** in the nonce (`> last_seen`, [crypto.md §6.1](crypto.md));
4. strips the framing header ([framing.md](framing.md)), parses payload ([payloads.md](payloads.md)).
Idempotent by `request_id`.
### 6.3 Store-and-Forward (`live=false`)
If the recipient is not connected when the message arrives:
1. The relay queues the message (`peer` as destination, sender pubkey, `nonce`, `ciphertext`,
`created_at`).
2. If the recipient is a **client** with a `device_token`, the relay sends a **push**
([server.md §5](server.md)).
3. On the recipient's (re)connection, the relay drains the queue **in FIFO order** over the WS,
then deletes delivered messages.
4. Queue TTL: **7 days**. Beyond that → silently dropped.
Queue limits per recipient: see §10.
### 6.4 Live Channel (`live=true`)
`live=true` selects a different delivery class:
| `live` | Relay semantics |
|--------|-----------------|
| `false` | **Store-and-forward**: if recipient is offline, queue (max 200, TTL 7d) and push. For approvals/clarifications. |
| `true` | **Route-or-fail**: forward **only** if the recipient is connected now. If offline → do NOT queue, do NOT push, reply to the sender with `PeerOffline { peer: <32B> }`. For state pulls and high-volume flows. Relay is **stateless** for this channel. |
On delivery the relay rewrites `Message.peer` from `to` (destination) to `from` (authenticated
sender), as in §6.2. `nonce`/`ciphertext` are never read.
### 6.5 Pull vs Notification: which traffic uses live (NORMATIVE)
The value of `live` is not a free choice: it depends on the **semantic nature** of the payload.
> **State pull → `live=true`. Event-driven notification that must wake the human → `live=false`
> (store-and-forward + push).**
A **pull** ("give me the current state") served stale is useless or harmful: route-or-fail is
correct — if the peer is absent, the sender knows immediately (`PeerOffline`) and shows an offline
state instead of hanging or receiving a stale snapshot hours later. A **notification** that must
reach an offline phone, however, *must* be able to wait in queue and be pushed.
| Payload | Direction | `live` | Why |
|---------|-----------|--------|-----|
| `inbox_request` (app open / reconnect) | client → agent | **`true`** | State pull: stale = useless. Agent offline → app learns immediately. |
| `inbox_update` in **response** to an `inbox_request` | agent → client | **`true`** | Client just asked: it is online by construction. |
| `inbox_update` for a **new event** (approval/clarification) | agent → client | **`false`** | Must reach an offline phone → queue + push. |
The sender, upon receiving `PeerOffline`, **stops** sending to that peer and retries on the next
`PresenceEvent { STATUS_ONLINE }` (§7) or on reconnect.
> **Why `PeerOffline` is needed even with presence.** Presence declares `ONLINE` with up to
> ~120 s delay on disconnect (idle-timeout). `PeerOffline` covers that blind window.
> Presence = *when to start*; `PeerOffline` = *correctness backstop*.
## 7. Presence
The relay exposes who is connected in the namespace. Scope is **strictly per namespace**: never
propagated outside. It only reveals pubkeys already known to the relay ([index.md §4.2](index.md)).
- `PresenceRequest {}` → relay replies `PresenceList { online: [<32B>, …] }` (snapshot, includes
the requester).
- On `AuthOk` of a connection, **and** on its close (WS close or 120 s idle-timeout), the relay
sends `PresenceEvent { pubkey: <32B>, status }` to **all other** connected namespace members.
Normative rules:
1. **Namespace scope**: no cross-namespace `PresenceEvent`.
2. **`OFFLINE` is best-effort and delayed** (up to ~120 s): not a guarantee of unreachability →
the live channel has its own backstop `PeerOffline` (§6.4).
3. Idempotency: two consecutive `ONLINE` events for the same pubkey = no-op on the receiver.
## 8. Keepalive
- The relay sends **native WS ping frames** every **30 s**; the peer responds with a **pong frame**.
These are native WebSocket opcodes, not protobuf messages.
- No traffic for **120 s** → the relay closes the connection.
- The agent reconnects with exponential backoff **1s, 2s, 4s, 8s, …, max 60s** (+ jitter).
- The client manages the WS according to its foreground/background lifecycle
(see the iOS app repository documentation).
## 9. Pairing
Explicit process: the agent opens a window; the relay accepts `role: pairing` only during the
window; the token is **single-use**.
```
AGENT (perm. WS) RELAY CLIENT (new WS)
│ ─ PairingStart ──────────▶│ │
│ {token, ttl} │ │
│ ◀─ PairingReady ──────────│ │
│ show QR ──────────────────────────────────────────── ▶│
│ │ ◀─ ws connect ─────────────│
│ │ ── Challenge ─────────────▶│
│ │ ◀─ Auth role:pairing ───────│
│ │ token, client pubkeys, │
│ │ device_token, platform │
│ │ verify: window? token ok? │
│ │ TTL? single-use? → consume│
│ │ ── AuthOk ────────────────▶│
│ ◀─ ClientPaired ──────────│ (client → close WS) │
│ client pubkeys, plat. │ │
│ ─ Authorize [.. new] ────▶│ (agent decides: authorise)│
│ ◀─ AuthorizeOk ───────────│ │
│ ─ PairingStop ───────────▶│ close window │
│ ◀─ PairingStopOk ─────────│ │
```
### 9.1 `PairingStart` (agent → relay)
```proto
PairingStart { pairing_token: <32B>, ttl: 300 }
```
- `pairing_token`: 32 random bytes (single-use bearer, [crypto.md §9](crypto.md)).
- `ttl`: seconds (default 300, max 600). The relay computes `expiry = now + ttl` and stores
`{token, namespace_id, expiry, consumed: false}`.
- Response: `PairingReady { ttl: 300 }`.
If the agent calls `PairingStart` again while a window is open, the **new** token replaces the
previous one (the old token is immediately invalidated).
### 9.2 `PairingStop` (agent → relay)
```proto
PairingStop {}
```
Closes the window; the current token is invalidated. Response: `PairingStopOk {}`.
### 9.3 Implicit Stop
On `ttl` expiry without `PairingStop`, the relay closes the window automatically. A consumed
token stays consumed; an unused token becomes unusable.
### 9.4 `ClientPaired` (relay → agent)
```proto
ClientPaired {
client_ed25519_pub: <32B>,
client_x25519_pub: <32B>,
platform: PLATFORM_IOS | PLATFORM_ANDROID
}
```
The agent:
1. computes `shared_secret = X25519(agent_x25519_priv, client_x25519_pub)` and the `aes_key`
([crypto.md §4-5](crypto.md));
2. persists the client (pubkeys, counters at 0);
3. applies the **authorisation policy** (auto or user confirmation) and sends updated `Authorize`;
4. waits for the client's `hello` E2E message for detailed `device_info`.
## 10. Limits & Quotas (NORMATIVE, relay side)
| Limit | Value | Error |
|-------|-------|-------|
| Max frame size (pre-auth and store-and-forward) | 64 KiB | `payload_too_large` |
| Max frame size (live channel, post-auth) | 512 KiB | `payload_too_large` |
| Challenge timeout | 30 s | `challenge_timeout` |
| Idle timeout | 120 s | (silent close) |
| Store-and-forward queue TTL | 7 days | (silent drop) |
| Max queued messages per client | 200 | `queue_full` (rejects new until drained) |
| Max new connections per IP | 30 / minute | `rate_limited` |
| Max messages per connection | 60 / minute | `rate_limited` |
| Inactive namespace TTL | 7 days | (garbage collection) |
| Pairing `ttl` | default 300, max 600 s | (clamped) |
The 512 KiB limit for the live channel applies **only** to `RelayFrame { message { live: true } }`
and **only after `auth_ok`**. Any pre-auth frame over 64 KiB → `payload_too_large` (denies
unauthenticated flood amplification).
Values are reasonable defaults; the relay exposes them via config. Per-IP quotas contain
unauthenticated flood on the public endpoint.
## 11. Namespace Lifecycle
```
agent auth → namespace created (if new)
├── agent disconnected → namespace "idle"
├── client connected → namespace active
├── agent reconnected → resumed
└── 7 days without any connection → deleted (queues, tokens, authorised list, device_tokens)
```
Deletion is never explicit. If the agent reconnects after GC, the namespace is recreated from
scratch (same `namespace_id`, because it derives from the same key) but **without** any clients:
devices must re-pair.
## 12. Errors
Uniform format:
```proto
Error { code: "<code>", message: "<description>" }
```
`AuthError` uses the same shape, emitted during the handshake instead of `AuthOk`.
| Code | Meaning |
|------|---------|
| `challenge_timeout` | No `Auth` within 30 s. |
| `invalid_signature` | Challenge signature not valid. |
| `unauthorized` | Client not in authorised list. |
| `not_found` | Namespace or recipient not found / outside namespace. |
| `pairing_closed` | Namespace not in pairing mode, or token expired/consumed/wrong. |
| `rate_limited` | Per-IP or per-connection quota exceeded. |
| `payload_too_large` | Frame exceeds size limit. |
| `queue_full` | Recipient queue full. |
| `bad_request` | Malformed protobuf, missing field, wrong byte length. |
On all `auth_error` cases and after fatal errors, the relay closes the WS.
## 13. Summary: Everything on One WS
| Direction | Frames |
|-----------|--------|
| relay → anyone | `Challenge`, `AuthOk` / `AuthError`, `Message` (with `from`), `Error`, native WS ping |
| agent → relay | `Auth`(agent), `Authorize`, `PairingStart`, `PairingStop`, `Message`, native WS pong |
| relay → agent | `ClientPaired`, `AuthorizeOk`, `PairingReady`, `PairingStopOk`, `Message`, `PresenceEvent` |
| client → relay | `Auth`(pairing/client), `Message`, `PresenceRequest`, native WS pong |
| relay → client | `Message`, `PeerOffline`, `PresenceList`, `PresenceEvent` |
No REST endpoint exists. `namespace_id` is never in a query string.
+342
View File
@@ -0,0 +1,342 @@
# Relay Server — Implementation
> Guide for the coding agent building `crates/skald-relay-server`. The **protocol** is in
> [relay-protocol.md](relay-protocol.md); the **cryptography** (which the relay barely touches)
> is in [crypto.md](crypto.md). Here: internal architecture, persistence, push bridge, deploy,
> quotas.
---
## 1. Role (and Non-Role)
The relay is the **only centralised component**. It does **only** four things:
1. **Authenticates** connections (Ed25519 challenge-response) and routes by `namespace_id`.
2. **Forwards** opaque messages between agent and clients of the same namespace.
3. **Store-and-forward**: queues for offline recipients.
4. **Push bridge**: for offline clients it talks to APNs (Apple) and FCM (Google).
It does **nothing else**: no business logic, no decryption, no content reading, no user accounts.
Deliberately dumb. Its only "truth" is: `pubkey → namespace`, `pubkey → device_token`, and a
FIFO queue of blobs.
### Zero-Trust: What It Means Here (precise)
The relay is **content-confidential**, **not** metadata-private (see [index.md §4](index.md)).
It sees pubkeys, device_tokens, IPs, the relationship graph, and timing; it does **not** see
content or detailed `device_info` (which travel E2E). Everything the relay persists is either
non-sensitive or E2E-encrypted.
---
## 2. Stack & Structure
Language: **Rust**. Static musl binary ~57 MB, ~30 MB RAM, cold start < 100 ms.
| Crate | Use |
|-------|-----|
| `axum` | HTTP server + WebSocket upgrade, healthcheck |
| `tokio` / `tokio-tungstenite` | async runtime + per-connection WS |
| `prost` | protobuf encode/decode (`RelayFrame` from `skald-relay-common`) |
| `sqlx` (sqlite) | persistence (namespaces, clients, queue) |
| `ed25519-dalek` = "2" | challenge-response signature verification |
| `sha2`, `hex` | hashing and encoding |
| `a2` | APNs HTTP/2 + JWT |
| `reqwest` | FCM HTTP v1 (Android) |
| `tracing` | structured logs (metrics only, **never** content) |
| `clap` | CLI flags |
| `governor` | per-IP / per-connection rate limiting |
```
crates/skald-relay-server/
├── Cargo.toml
└── src/
├── main.rs # config, init, axum server, graceful shutdown
├── ws.rs # WS handler: challenge → auth(role) → forward loop
├── auth.rs # signature verification, namespace_id derivation, role gating
├── routing.rs # live connection registry (namespace → agent/clients)
├── store.rs # sqlx: namespaces, clients, queue, pairing
├── push.rs # APNs + FCM bridge, content-in-push vs wake
├── limits.rs # quotas, rate-limit, timeouts
└── types.rs # serde types for JSON (used only for push payloads / logging)
```
> **Shared crate.** Frame types and auth crypto (signature verification + `namespace_id`
> derivation) are **common** with the plugin and live in `crates/skald-relay-common`.
> The relay depends on that crate. X25519/HKDF/AES-GCM remain in the shared crate for E2E
> (plugin + app) and for the `gen-vectors` binary, not used by the relay itself.
---
## 3. Data Model (SQLite)
Minimal schema. **No sensitive data in plaintext**: `ciphertext` is E2E; pubkeys are public
identifiers.
```sql
CREATE TABLE namespaces (
namespace_id TEXT PRIMARY KEY, -- hex(SHA256(domain‖pub)), immutable
agent_ed25519_pub BLOB NOT NULL UNIQUE, -- 32B, binds id to key
created_at INTEGER NOT NULL, -- unix ms
last_active INTEGER NOT NULL, -- for 7-day GC
-- pairing window (at most one active per namespace):
pairing_token BLOB, -- 32B random, NULL if closed
pairing_expiry INTEGER, -- unix ms
pairing_consumed INTEGER NOT NULL DEFAULT 0 -- 0/1 single-use
);
CREATE TABLE clients (
namespace_id TEXT NOT NULL REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
client_ed25519_pub BLOB NOT NULL, -- 32B, routing + auth
client_x25519_pub BLOB NOT NULL, -- 32B, opaque (forwarded to agent)
device_token TEXT, -- push token (APNs/FCM)
platform TEXT NOT NULL, -- 'ios' | 'android'
state TEXT NOT NULL, -- 'pending' | 'authorized'
last_seen INTEGER, -- unix ms
PRIMARY KEY (namespace_id, client_ed25519_pub)
);
CREATE TABLE queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace_id TEXT NOT NULL REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
to_pub BLOB NOT NULL, -- 32B recipient
from_pub BLOB NOT NULL, -- 32B sender (guaranteed by relay)
nonce BLOB NOT NULL, -- 12B
ciphertext BLOB NOT NULL, -- opaque (ciphertext‖tag)
created_at INTEGER NOT NULL -- unix ms (for 7-day TTL)
);
CREATE INDEX idx_queue_dest ON queue(namespace_id, to_pub, id);
```
Notes:
- `client_x25519_pub` is persisted for **robustness** (re-forwarding `ClientPaired` if the agent
missed it), even though the relay does not use it for crypto.
- The relay does NOT store `shared_secret`/`aes_key` (it neither has them nor can compute them).
- `state='pending'` until the agent sends `Authorize` including the pubkey (→ `authorized`).
A `role:"client"` connection is only accepted from `authorized`.
### ⚠️ Constraint: SQLite on EFS ⇒ Single Instance
In v1 the relay runs as a **single Fargate task** with SQLite on an EFS volume. SQLite on NFS/EFS
**does not support** concurrent writes from multiple processes (unreliable locking → corruption).
Therefore:
- **Do NOT** scale horizontally with this configuration (no HA, no multi-task).
- Store-and-forward assumes a **single writer**.
- **Scale path** (post-v1, if HA is needed): replace `store.rs` with Postgres (RDS) for the
queue and a distributed connection registry (e.g. Redis pub/sub) for cross-instance routing.
The `store.rs` API is designed to make this substitution localised.
---
## 4. Concurrency & Routing
Model: **one Tokio task per WS**.
- `routing.rs` holds in memory `DashMap<namespace_id, NamespaceConns>` where
`NamespaceConns { agent: Option<Sender>, clients: HashMap<pubkey, Sender> }` and `Sender` is
a `tokio::sync::mpsc::Sender<RelayFrame>` toward that WS's task.
- **Forwarding**: on receiving a `Message` from an authenticated WS, check `peer` (destination):
- if the recipient has a live connection in the same namespace → send on its `Sender`;
- otherwise → `store::enqueue(...)` and, for a client, `push::notify(...)`. Unless
`Message.live=true`, in which case → send `PeerOffline { peer }` back to the sender.
- **Single agent**: one `agent` connection per namespace; a new one displaces the old (close old).
- **Single client per pubkey**: same for devices.
- **Keepalive**: ping task every 30 s; close on 120 s silence
([relay-protocol.md §8](relay-protocol.md)).
### Store-and-Forward (delivery)
```rust
async fn deliver_pending(tx: &Sender<RelayFrame>, store: &Store,
ns: &str, to_pub: &[u8;32]) -> anyhow::Result<()> {
for m in store.fetch_pending(ns, to_pub).await? { // ORDER BY id ASC (FIFO)
tx.send(build_message_frame(m.from_pub, m.nonce, m.ciphertext, false)).await?;
store.delete_pending(m.id).await?; // delete after delivery
}
Ok(())
}
```
Queue full (> 200 for recipient, [relay-protocol.md §10](relay-protocol.md)) → reject new
messages with `queue_full` until drained. TTL: a periodic task deletes messages older than 7 days
and namespaces inactive for 7 days.
---
## 5. Push (APNs / FCM Bridge)
When a message is destined for an **offline client** with a `device_token`, the relay sends a
push. Two modes, decided by the **size of the encrypted blob**:
### 5.1 Content-in-Push (preferred, enables "approve from notification")
If `len(raw ciphertext)` fits within the payload limit (**APNs ~4 KiB**, **FCM ~4 KiB**), the
relay includes the **already E2E-encrypted blob** in the push. The device decrypts it in the
Notification Service Extension and shows a rich notification with Approve/Reject actions,
**without** opening the app.
**APNs payload**:
```json
{
"aps": {
"alert": { "title": "Skald", "body": "Action required" },
"badge": 1,
"sound": "default",
"mutable-content": 1,
"category": "skald_inbox"
},
"d": {
"ns": "<namespace_id hex>",
"from": "<agent_ed25519_pub hex>",
"n": "<nonce hex 24>",
"c": "<ciphertext base64>"
}
}
```
- `mutable-content: 1` activates the Notification Service Extension (decrypts `d.c`).
- `aps.alert` is a **generic fallback** shown if the NSE fails: **never** sensitive content.
- The relay does NOT know what is in `d.c`: it copies it as-is from the queue.
> Note: inside `d`, the values use hex/base64 encoding for JSON compatibility (nonce hex 24 chars,
> ciphertext base64). This is the one context where the encoding conventions from
> [index.md §5](index.md) apply outside the E2E JSON payload.
### 5.2 Wake-Only (fallback when blob exceeds limit)
```json
{
"aps": { "alert": { "title":"Skald", "body":"Action required" },
"badge":1, "sound":"default", "content-available":1 },
"d": { "ns": "<namespace_id hex>", "wake": true }
}
```
The device wakes, opens a **temporary WS**, downloads queued messages, and shows the Inbox.
No content in the push.
> **Choice rule (normative):** content-in-push if `len(raw_ciphertext_bytes) <= 3500` (after
> base64-encoding into JSON it will be ≤ ~4666 chars), otherwise wake-only. Conservative threshold
> to leave room for other fields. Keep `summary`/`detail` in payloads small to stay in the
> preferred case.
### 5.3 FCM (Android)
Use **FCM HTTP v1** with a **data-only message** (`"data": { … }`) so the app always handles
decryption (even in background), avoiding automatic display of an unencrypted `notification`.
Fields `ns`/`from`/`n`/`c` as above. Priority `high`.
### 5.4 Push Key Management
| Secret | Where | How |
|--------|-------|-----|
| APNs `.p8` (Apple) | **relay only** | AWS Secrets Manager in prod; `config/apns-key.json` (git-ignored) locally |
| FCM service account JSON (Google) | **relay only** | Secrets Manager / local file |
Never in the app, never in the plugin. At startup the relay loads secrets and generates:
- **APNs JWT** (ES256, valid 60 min) held in memory, **refreshed every ~30 min** (never more
than once every 20 min, per Apple's rules). No key on disk beyond the `.p8`.
- **FCM OAuth token** (from the service account) with auto-refresh.
Example APNs secret in Secrets Manager:
```json
{ "team_id":"ABC123DEFG", "key_id":"XYZ789ABCD",
"private_key":"-----BEGIN PRIVATE KEY-----\nMIGTA…\n-----END PRIVATE KEY-----" }
```
Minimal IAM for the ECS task:
```json
{ "Effect":"Allow", "Action":"secretsmanager:GetSecretValue",
"Resource":"arn:aws:secretsmanager:REGION:ACCOUNT:secret:skald/push-keys-*" }
```
---
## 6. Security Checklist
- [ ] **WSS mandatory**: reject plain `ws://`.
- [ ] Verify Ed25519 signature **before** any other logic; reject malformed input with `bad_request`.
- [ ] `namespace_id` recomputed from pubkey, **never** trusted from client input.
- [ ] Pairing token and tag comparisons in **constant-time** (`subtle`).
- [ ] `PairingStart` token **single-use** enforced atomically (`UPDATE … WHERE consumed=0`).
- [ ] Role gating: `client` only if `authorized`; `pairing` only if window open + token valid.
- [ ] **Rate-limit** per-IP on new connections and per-connection on messages (`governor`).
- [ ] Frame size limit 64 KiB (pre-auth + store-and-forward), 512 KiB (live channel post-auth);
`payload_too_large` + close on exceeded.
- [ ] **No content in logs**: log only `namespace_id`, truncated pubkeys, codes, counts, latencies.
**Never** `ciphertext`, `nonce`, full `device_token` (truncate/hash).
- [ ] `Authorize` shrink → close the revoked client's WS + **purge their queue** + forget `device_token`.
- [ ] 7-day GC for namespaces/queues.
---
## 7. Startup & Shutdown
1. `main.rs` loads config (CLI/env): port, DB path, push key source, thresholds.
2. Load push keys (Secrets Manager or file). Generate APNs JWT + FCM token (refresh task).
3. Initialise SQLite (migrations via `sqlx::migrate!`).
4. Start axum on `0.0.0.0:{port}`; route `GET /healthz` → 200; `GET /v1/ws` → upgrade.
5. **Graceful shutdown** on SIGTERM/SIGINT: stop accepting, drain WS connections, flush queue,
close DB.
### Logging
`main.rs` writes logs to both **stdout** and a file at **`logs/skald-relay.log`** (daily rotation
via `tracing-appender`), aligned with the main app. Log level controlled by `RUST_LOG`; default
`skald_relay_server=info,info`. In development:
```sh
RUST_LOG=skald_relay_server=debug # auth, routing, queue drain
RUST_LOG=skald_relay_server=trace # frame-level tracing
```
Invariant: **never log content** — only `namespace_id`, truncated pubkeys, codes, counts (see §6).
---
## 8. Deploy
| Aspect | v1 choice |
|--------|-----------|
| Compute | AWS ECS **Fargate**, **1 task** (§3 constraint) |
| Container | musl static, `FROM scratch`, ~7 MB |
| Storage | SQLite on **EFS** (persistent across restarts) |
| Push keys | Secrets Manager (`skald/push-keys`) |
| Domain/TLS | `relay.skaldagent.net` via ALB + ACM (free TLS) |
| Logs | CloudWatch (metrics only) |
| Cost | ~$510/month Fargate + ~$0.40 Secrets Manager |
```dockerfile
FROM clux/muslrust:stable AS build
COPY . /src
WORKDIR /src
RUN cargo build --release --target x86_64-unknown-linux-musl -p skald-relay-server --bin skald-relay-server
FROM scratch
COPY --from=build /src/target/x86_64-unknown-linux-musl/release/skald-relay-server /skald-relay-server
EXPOSE 8080
ENTRYPOINT ["/skald-relay-server"]
```
### Self-Hosting
Anyone can host their own relay: an Apple Developer Key ($99/year) for APNs (and/or a Firebase
project for FCM) is required. `docker compose` with local SQLite, or deploy on your own cloud.
The relay is open source and interoperable with any agent/app conforming to these documents.
---
## 9. Definition of Done
- [ ] `cargo build --release` produces a musl static binary.
- [ ] An agent can authenticate, create the namespace, start/stop pairing, authorize.
- [ ] A client can pair, then connect as `client` only after `authorize`.
- [ ] Messages routed live; store-and-forward + FIFO delivery on reconnect.
- [ ] Push content-in-push below threshold, wake-only above; APNs and (at least stub) FCM working.
- [ ] Revocation via `Authorize` shrink closes WS and purges queue.
- [ ] Live channel: `PeerOffline` sent correctly; no queue/push for `live=true` messages.
- [ ] Presence: `PresenceList` on `PresenceRequest`; `PresenceEvent` on connect/disconnect.
- [ ] Quotas/rate-limit active; no content in logs.
- [ ] 7-day GC verified.
- [ ] Relay never alters `nonce`/`ciphertext` (test: bytes identical in/out).
+243
View File
@@ -0,0 +1,243 @@
# Crypto Test Vectors — Interop
> Purpose: guarantee that **independent implementations** (relay/plugin in Rust, app in Swift,
> app in Kotlin) produce **the same bytes** from the same inputs. Without these vectors two
> "reasonable" implementations can silently diverge (KDF, byte order, AAD, nonce construction,
> plaintext framing) and never be able to decrypt each other's output.
>
> **Method (important):** the **source of truth** is the *reference generator* in §3 (Rust). The
> expected values in the tables MUST be produced by **running that tool** and then **committed**
> to this file. They are not hand-transcribed (manual transcription of crypto output causes
> errors). Every other implementation MUST reproduce those outputs exactly.
>
> **Framing:** the plaintext that is encrypted is not the raw JSON but a versioned envelope:
> `plaintext = version(0x01) ‖ comp(1B) ‖ payload(JSON)`. For payloads ≤ 1024 B, `comp = 0x00`
> (no compression). Vectors V14/V17 account for this framing — an implementor decrypting V14/V17
> must obtain the *framed* plaintext, then extract `plaintext[0]` (version), `plaintext[1]` (comp),
> and the payload.
Constants and encoding: [crypto.md §1](crypto.md), [index.md §5](index.md).
---
## 1. Fixed Inputs (deterministic)
All vectors start from these inputs. Bytes expressed in hex.
```
SEED_AGENT (32B) = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
SEED_CLIENT (32B) = 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f
CHALLENGE_NONCE (32B) = aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899
COUNTER_AGENT_TO_CLIENT = 1 // u64
COUNTER_CLIENT_TO_AGENT = 1 // u64
PLAINTEXT_A2C (inbox_update, agent→client), exact UTF-8:
{"v":1,"kind":"inbox_update","id":"00000000-0000-4000-8000-000000000001","ts":1750000000000,"badge":1,"approvals":[{"request_id":"appr_test_1","tool_name":"send_email","agent_label":"Skald","summary":"Test","created_at":1750000000000}],"clarifications":[]}
PLAINTEXT_C2A (approval_response, client→agent), exact UTF-8:
{"v":1,"kind":"approval_response","id":"00000000-0000-4000-8000-000000000002","ts":1750000000000,"request_id":"appr_test_1","decision":"approved"}
```
> The two plaintext strings are **fixed** JSON (no spaces, no field reordering) **only for the
> vector**: in production JSON is not canonicalised (it is encrypted as a blob and re-parsed).
## 2. Vector Table
| # | Value | Definition | Expected (hex / base64) |
|----|-------|-------------|------------------------|
| V1 | `agent_x25519_priv` | HKDF(SEED_AGENT, salt=`skald-kdf-v1`, info=`x25519`, 32) | `<gen>` |
| V2 | `agent_x25519_pub` | X25519(V1, base) | `<gen>` |
| V3 | `agent_ed25519_priv` | HKDF(SEED_AGENT, salt=`skald-kdf-v1`, info=`ed25519`, 32) | `<gen>` |
| V4 | `agent_ed25519_pub` | Ed25519 pub from V3 | `<gen>` |
| V5 | `client_x25519_priv` | HKDF(SEED_CLIENT, …, info=`x25519`, 32) | `<gen>` |
| V6 | `client_x25519_pub` | X25519(V5, base) | `<gen>` |
| V7 | `client_ed25519_priv` | HKDF(SEED_CLIENT, …, info=`ed25519`, 32) | `<gen>` |
| V8 | `client_ed25519_pub` | Ed25519 pub from V7 | `<gen>` |
| V9 | `namespace_id` | hex(SHA256(`skald-namespace-v1` ‖ 0x00 ‖ V4)) | `<gen>` |
| V10 | `shared_secret` | X25519(V1, V6) **==** X25519(V5, V2) | `<gen>` |
| V11 | `aes_key` | HKDF(V10, salt=`skald-session-v1`, info=`aes-256-gcm`, 32) | `<gen>` |
| V12 | `nonce_a2c` | `00000001` ‖ u64_be(1) = 12B | `000000010000000000000001` |
| V13 | `aad_a2c` (96B) | `ns_raw ‖ V4 ‖ V8` (ns_raw = raw 32B of SHA256, NOT hex; from=agent, to=client) | `<gen>` |
| V14 | `sealed_a2c` | AES-256-GCM.seal(V11, V12, V13, **PT_FRAMED_A2C**) = ct‖tag | `<gen base64>` |
| V15 | `nonce_c2a` | `00000002` ‖ u64_be(1) (12B) | `000000020000000000000001` |
| V16 | `aad_c2a` (96B) | `ns_raw ‖ V8 ‖ V4` | `<gen>` |
| V17 | `sealed_c2a` | AES-256-GCM.seal(V11, V15, V16, **PT_FRAMED_C2A**) | `<gen base64>` |
| V18 | `auth_sig_client` | Ed25519_sign(V7, `skald-relay-auth-v1` ‖ 0x00 ‖ CHALLENGE_NONCE) | `<gen>` |
| | **PT_FRAMED_A2C** | `0x01 ‖ 0x00 ‖ PLAINTEXT_A2C` ([framing.md §1](framing.md)) — what is fed to AES-GCM for V14 | 258B, see §3 |
| | **PT_FRAMED_C2A** | `0x01 ‖ 0x00 ‖ PLAINTEXT_C2A` ([framing.md §1](framing.md)) — what is fed to AES-GCM for V17 | 148B, see §3 |
V12 and V15 are deterministic by construction (already filled in). All other `<gen>` values
must be filled by running the tool in §3.
## 3. Reference Generator (Rust)
Lives in `crates/skald-relay-common` as the `gen-vectors` binary.
```sh
cargo run -p skald-relay-common --bin gen-vectors
```
The generator uses the shared library (`skald_relay_common::crypto`). The Rust snippet below is
a reference for independent implementations (Swift/Kotlin).
```rust
// Framing (framing.md §1):
// plaintext = version(0x01) ‖ comp(1B) ‖ payload(JSON)
// comp=0x00 for payload ≤ 1024 B (no compression)
// What is encrypted is the FRAMED plaintext, not the raw JSON.
use hkdf::Hkdf; use sha2::{Sha256, Digest};
use ed25519_dalek::{SigningKey, Signer};
use x25519_dalek::{StaticSecret, PublicKey};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::{Aead, Payload}};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
fn hkdf(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8;32] {
let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
let mut out = [0u8;32]; hk.expand(info, &mut out).unwrap(); out
}
fn derive(seed: &[u8;32]) -> (StaticSecret, [u8;32], SigningKey, [u8;32]) {
let x = StaticSecret::from(hkdf(seed, b"skald-kdf-v1", b"x25519"));
let xp = PublicKey::from(&x).to_bytes();
let e = SigningKey::from_bytes(&hkdf(seed, b"skald-kdf-v1", b"ed25519"));
let ep = e.verifying_key().to_bytes();
(x, xp, e, ep)
}
fn frame_payload(payload: &[u8]) -> Vec<u8> {
let mut framed = vec![0x01u8]; // version
framed.push(0x00); // comp = none (payload < 1024B)
framed.extend_from_slice(payload);
framed
}
fn main() {
let seed_a: [u8;32] = (0u8..32).collect::<Vec<_>>().try_into().unwrap();
let seed_c: [u8;32] = (32u8..64).collect::<Vec<_>>().try_into().unwrap();
let (xa, xa_pub, ea, ea_pub) = derive(&seed_a);
let (xc, xc_pub, ec, ec_pub) = derive(&seed_c);
let mut h = Sha256::new();
h.update(b"skald-namespace-v1"); h.update([0u8]); h.update(ea_pub);
let ns_raw = h.finalize();
let ns_hex = hex::encode(ns_raw);
let s1 = xa.diffie_hellman(&PublicKey::from(xc_pub));
let s2 = xc.diffie_hellman(&PublicKey::from(xa_pub));
assert_eq!(s1.as_bytes(), s2.as_bytes(), "ECDH mismatch");
let aes_key = hkdf(s1.as_bytes(), b"skald-session-v1", b"aes-256-gcm");
let cipher = Aes256Gcm::new((&aes_key).into());
let mut n_a2c = [0u8;12]; n_a2c[..4].copy_from_slice(&[0,0,0,1]);
n_a2c[4..].copy_from_slice(&1u64.to_be_bytes());
let mut aad_a2c = Vec::new(); aad_a2c.extend_from_slice(&ns_raw);
aad_a2c.extend_from_slice(&ea_pub); aad_a2c.extend_from_slice(&ec_pub);
let pt_a2c = br#"{"v":1,"kind":"inbox_update","id":"00000000-0000-4000-8000-000000000001","ts":1750000000000,"badge":1,"approvals":[{"request_id":"appr_test_1","tool_name":"send_email","agent_label":"Skald","summary":"Test","created_at":1750000000000}],"clarifications":[]}"#;
let framed_a2c = frame_payload(pt_a2c);
let sealed_a2c = cipher.encrypt(Nonce::from_slice(&n_a2c),
Payload{ msg: &framed_a2c, aad: &aad_a2c }).unwrap();
let mut m = Vec::new(); m.extend_from_slice(b"skald-relay-auth-v1"); m.push(0);
m.extend_from_slice(&hex::decode("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899").unwrap());
let sig = ec.sign(&m);
println!("V2 agent_x25519_pub = {}", hex::encode(xa_pub));
println!("V4 agent_ed25519_pub = {}", hex::encode(ea_pub));
println!("V6 client_x25519_pub = {}", hex::encode(xc_pub));
println!("V8 client_ed25519_pub = {}", hex::encode(ec_pub));
println!("V9 namespace_id = {}", ns_hex);
println!("V10 shared_secret = {}", hex::encode(s1.as_bytes()));
println!("V11 aes_key = {}", hex::encode(aes_key));
println!("V13 aad_a2c = {}", hex::encode(&aad_a2c));
println!("V14 sealed_a2c (b64) = {}", B64.encode(&sealed_a2c));
println!("V18 auth_sig_client = {}", hex::encode(sig.to_bytes()));
println!("# PT_FRAMED_A2C = {}", hex::encode(&framed_a2c));
}
```
---
## 4. Canonical Outputs (committed once)
```
# Generated by `cargo run -p skald-relay-common --bin gen-vectors`
# Framing (framing.md §1): the bytes fed to AES-GCM are
# plaintext = version(0x01) ‖ comp(1B) ‖ payload(JSON).
# Below threshold (1024B), comp = 0x00. V14/V17 seal the FRAMED plaintext.
V1 agent_x25519_priv = 497a4febd79a47e0a0b9522273ef8db2588b113e3d58365e4462e0899b932495
V2 agent_x25519_pub = 4fcb9922300372851653f0d8a0d48855674b6f6095e3770273d212bcaf51bc64
V3 agent_ed25519_priv = 13b9de6a991a9d382dec70bdeb7d8b36327ebcb81a45fa7ac7829376a695f433
V4 agent_ed25519_pub = b3e202f4ac99fd9929da47df20adedd5b2598411a466a229f086eda3467ffa7b
V5 client_x25519_priv = 5cc48fd4f6fa941053037ba6b8b1ed1daad48764d0084670307d79c4809b28a8
V6 client_x25519_pub = fc472466d9013da9a50a49b6031cde99c1cfd11c87ee04fe4da952417a1f7337
V7 client_ed25519_priv= cbaabfd5b937657cf4e7964ba87c975401337f3ce0d27026a404f102bd7c68c8
V8 client_ed25519_pub = 12355ea750e60d6370ba6776037f25062f6c9450c5009669884895fd5b377a18
V9 namespace_id = f7d340d3c3f0b0052fa904ba60ebd38a0f7e7d10672ac80648991a2c632c9e58
V10 shared_secret = 66c51034dd6360b9cdddc495049463b0191d7f3bddce9ea6f2975c85d471540a
V11 aes_key = 74fb4ffcbbe069859cfb0790023811554dad328d9f4ac4a1d28077086e33a4e7
V12 nonce_a2c = 000000010000000000000001
V13 aad_a2c = f7d340d3c3f0b0052fa904ba60ebd38a0f7e7d10672ac80648991a2c632c9e58b3e202f4ac99fd9929da47df20adedd5b2598411a466a229f086eda3467ffa7b12355ea750e60d6370ba6776037f25062f6c9450c5009669884895fd5b377a18
V14 sealed_a2c (b64) = FrtkSke7RpPUAg24p1XPZpswSX3WoDv/Y2IUvvaahY5+2CcdHXKvyRhsdjqCVa7zVs9Y0a4SZ1a7ddsPKYPz0BX/Ur3nDOOwTySKaDqT8fca//XpJyVkd60TxbfZkILNejruBLX7y2he3OI6MYu2TrmgmUSrqqfJ6NX9Go5gaKoyenXoVKOY3NKuSNmIEyIzYEkZj8uImEgah9BG/6lI59a1LWfJDlgggFf5KWkoPJHHAHA4546aPFEk5iG+3WLcjq6yiiE0p/umsr5jG2AjnkvVWYpYe8paZ4sWy/HkIYkzo9zJAGnmvK9UBHJupZABSioeRYFW2WN6ierUHbp2WyQxYvcb0x/K73Lmp4hSg6DS3w==
V15 nonce_c2a = 000000020000000000000001
V16 aad_c2a = f7d340d3c3f0b0052fa904ba60ebd38a0f7e7d10672ac80648991a2c632c9e5812355ea750e60d6370ba6776037f25062f6c9450c5009669884895fd5b377a18b3e202f4ac99fd9929da47df20adedd5b2598411a466a229f086eda3467ffa7b
V17 sealed_c2a (b64) = WYOy3vzVD+DI6lZQ4atH8g2yPfcgSo9uNNsfkWUoRD+KXWaKlDaazN6AmYAM+S3tGEVimk1HedYUJ4QrzBZJYoeBUYSxiz7WpRnqgD9mumHp8GCypttt9+/FNc7tc/zLERvtW2GfsVJSKrs0MpKFTNCauoYLdFuKdWy/A2QykrZXlySbwaNXPnMOA3ApeEsPidPHutom7G6ksgSz0qhuceIbNt4=
V18 auth_sig_client = ae38491a1f25bb5fb11f0b17e3d344412bfc927461b6517e9a0ab6a64020054677f59490af026f34c81d9378d4daae4823109ca2d1afbf4ff00230a038270002
# Framed plaintexts (input to AES-GCM, framing.md §1):
PT_FRAMED_A2C = 01007b2276223a312c226b696e64223a22696e626f785f757064617465222c226964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303031222c227473223a313735303030303030303030302c226261646765223a312c22617070726f76616c73223a5b7b22726571756573745f6964223a22617070725f746573745f31222c22746f6f6c5f6e616d65223a2273656e645f656d61696c222c226167656e745f6c6162656c223a22536b616c64222c2273756d6d617279223a2254657374222c22637265617465645f6174223a313735303030303030303030307d5d2c22636c6172696669636174696f6e73223a5b5d7d
PT_FRAMED_C2A = 01007b2276223a312c226b696e64223a22617070726f76616c5f726573706f6e7365222c226964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303032222c227473223a313735303030303030303030302c22726571756573745f6964223a22617070725f746573745f31222c226465636973696f6e223a22617070726f766564227d
# framed_a2c[:2] = 0100 (version=01, comp=00 = none for <1024 B)
# framed_c2a[:2] = 0100 (version=01, comp=00 = none for <1024 B)
# PT_FRAMED_A2C.len = 258 (PLAINTEXT_A2C.len + 2 framing header bytes)
# PT_FRAMED_C2A.len = 148 (PLAINTEXT_C2A.len + 2 framing header bytes)
```
Once committed, these values are **immutable**. If they change after a library update, it is a
**bug** (likely a KDF/encoding/framing divergence): investigate, do not blindly update.
> **Interop invariant:** the relay's `verify_strict` MUST accept signatures produced by the iOS
> client. Verified by cross-compat tests in
> `crates/skald-relay-server/src/auth.rs::tests::challenge_verifies_cryptokit_signature` and
> `SkaldInboxTests/SkaldInboxTests.swift::testAuthSignatureCrossCompatWithDalek`.
---
## 5. Swift Verification (CryptoKit)
Unit test in the app: derive from `SEED_AGENT`/`SEED_CLIENT` and **assert** equality with §4.
```swift
func testInteropVectors() throws {
let seedA = Data((0..<32).map { UInt8($0) })
let (signA, agreeA) = deriveKeys(seed: seedA) // crypto.md §3
XCTAssertEqual(agreeA.publicKey.rawRepresentation.hex, "<V2>")
XCTAssertEqual(signA.publicKey.rawRepresentation.hex, "<V4>")
let seedC = Data((32..<64).map { UInt8($0) })
let (signC, agreeC) = deriveKeys(seed: seedC)
let shared = try agreeC.sharedSecretFromKeyAgreement(with: agreeA.publicKey)
let key = shared.hkdfDerivedSymmetricKey(using: SHA256.self,
salt: Data("skald-session-v1".utf8),
sharedInfo: Data("aes-256-gcm".utf8), outputByteCount: 32)
// Decrypt: strip framing header from the decrypted bytes, compare with PLAINTEXT_A2C
// open(sealed=base64(<V14>), nonce=<V12>, aad=<V13>) plaintext_framed
// plaintext_framed[2:] == PLAINTEXT_A2C
}
```
If **even one** vector does not match, the app will not be interoperable: fix it before
continuing.
---
## 6. Interop Checklist (for each implementation)
- [ ] V2/V4/V6/V8: same pubkeys from seed → **identical KDF derivation**.
- [ ] V9: same `namespace_id`**correct domain + byte order**.
- [ ] V10: ECDH symmetric and equal → **correct X25519** (no ed25519-as-x25519).
- [ ] V11: same `aes_key`**correct session HKDF**.
- [ ] V14/V17: mutually decryptable → **correct nonce(DIR‖counter) + AAD + GCM**.
- [ ] V14/V17: decrypted framed plaintext starts with `0x01 0x00`, remainder == PLAINTEXT_*
**framing.md §1 implemented correctly**.
- [ ] V18: valid and reproducible signature → **correct auth domain separation**.
- [ ] Cross-language round-trip: app decrypts a `sealed` produced by the Rust plugin and vice versa.