First Version
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 5–10×.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 ~5–7 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 | ~$5–10/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).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user