First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
# Crypto Contract
> This file is the **single source of truth** for cryptography. Relay, plugin, and app MUST
> implement exactly what follows. Any divergence breaks interoperability. The words MUST / MUST NOT /
> SHOULD carry the RFC 2119 meaning. Verify your implementation against
> [test-vectors.md](test-vectors.md) **before** integrating.
Field encoding: see [index.md §5](index.md). In short: keys/signatures/ids/nonces in **lowercase
hex**, ciphertext in **standard base64 with padding**.
---
## 1. Domain Constants (NORMATIVE)
All strings are ASCII/UTF-8, without NUL terminator unless noted as `\x00`.
| Name | Value (bytes) | Use |
|------|---------------|-----|
| `KDF_SALT` | `"skald-kdf-v1"` | HKDF seed → keypair (§3) |
| `KDF_INFO_X25519` | `"x25519"` | HKDF info, X25519 branch (§3) |
| `KDF_INFO_ED25519` | `"ed25519"` | HKDF info, ed25519 branch (§3) |
| `SESSION_SALT` | `"skald-session-v1"` | HKDF shared_secret → aes_key (§5) |
| `SESSION_INFO` | `"aes-256-gcm"` | HKDF info, AEAD key (§5) |
| `NS_DOMAIN` | `"skald-namespace-v1"` | `namespace_id` derivation (§7) |
| `AUTH_DOMAIN` | `"skald-relay-auth-v1"` | Challenge-response signature (§8) |
| `NONCE_DIR_AGENT_TO_CLIENT` | `0x00 0x00 0x00 0x01` | Nonce prefix, agent→client direction (§6) |
| `NONCE_DIR_CLIENT_TO_AGENT` | `0x00 0x00 0x00 0x02` | Nonce prefix, client→agent direction (§6) |
| `PIPE_AUTH_DOMAIN` | `"skald-pipe-auth-v1"` | Pipe data-plane challenge signature ([pipe.md §3.1](pipe.md)) |
| `PIPE_KDF_SALT` | `"skald-pipe-v1"` | HKDF salt: ephemeral ECDH → per-pipe AES key ([pipe.md §4](pipe.md)) |
| `PIPE_KDF_INFO` | `"pipe-aes-256-gcm"` | HKDF info, per-pipe AES key ([pipe.md §4](pipe.md)) |
| `NONCE_DIR_PIPE_INITIATOR` | `0x00 0x00 0x00 0x03` | Nonce prefix, pipe initiator→responder ([pipe.md §4](pipe.md)) |
| `NONCE_DIR_PIPE_RESPONDER` | `0x00 0x00 0x00 0x04` | Nonce prefix, pipe responder→initiator ([pipe.md §4](pipe.md)) |
Algorithms: **X25519** (RFC 7748), **Ed25519** (RFC 8032), **HKDF-SHA256** (RFC 5869),
**AES-256-GCM** (NIST SP 800-38D), **SHA-256** (FIPS 180-4).
> The **pipe** (relayed byte-stream, [pipe.md](pipe.md)) reuses this entire suite — X25519 ECDH,
> HKDF, AES-256-GCM with the `DIR ‖ counter` nonce (§6) — keyed by a **per-pipe ephemeral** DH
> (Perfect Forward Secrecy), with `aad = connection_id`. No new primitives.
---
## 2. Persistent Material: the Seed
Every actor with a cryptographic identity (agent and each client) holds **one single persistent
secret**: a **32-byte seed** generated from CSPRNG.
- Agent: `data/relay/seed`, 32-byte binary file, permissions `0600`. Generated on first start.
- iOS client: 32 bytes in Keychain, attribute `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`.
- Android client: 32 bytes in Keystore / EncryptedSharedPreferences (hardware-backed if available).
Two keypairs are derived from this seed (§3). The seed MUST NOT leave the device and MUST NOT
ever be transmitted. Private keys are regenerated from the seed on each startup; they are not
persisted separately.
> **Why two keypairs?** Ed25519 is for **signing** (authentication toward the relay).
> X25519 is for **ECDH** (E2E key agreement). They are related curves with distinct roles and APIs
> on all platforms (CryptoKit separates them: `Curve25519.Signing` vs `Curve25519.KeyAgreement`).
> **Never** convert an ed25519 key into X25519 by reinterpreting the bytes: this is cryptographically
> wrong. Both are derived independently from the seed.
---
## 3. Key Derivation from Seed (NORMATIVE)
Identical across all platforms. `HKDF` = HKDF-SHA256, 32-byte output.
```
x25519_priv = HKDF(ikm = seed, salt = KDF_SALT, info = KDF_INFO_X25519, len = 32)
ed25519_priv = HKDF(ikm = seed, salt = KDF_SALT, info = KDF_INFO_ED25519, len = 32)
```
- `x25519_priv` (32 bytes) is the X25519 private **scalar**. Libraries apply RFC 7748 *clamping*
internally; do not clamp manually. `x25519_pub = X25519(x25519_priv, basepoint)`.
- `ed25519_priv` (32 bytes) is the **Ed25519 seed** (the 32-byte "private key" of RFC 8032).
`ed25519_pub` (32 bytes) is derived from it per RFC 8032.
> Terminology note: in Ed25519, the 64-byte "private key" is `seed(32) ‖ pub(32)`. Here the secret
> material is the **32-byte seed** (`ed25519_priv` above). Do not confuse the 32 bytes of *our* seed
> (§2) with the 32 bytes of the *Ed25519 seed* (HKDF output): they are different things.
### Rust (agent / relay-side verification)
```rust
use hkdf::Hkdf;
use sha2::Sha256;
use ed25519_dalek::SigningKey; // ed25519-dalek = "2"
use x25519_dalek::{StaticSecret, PublicKey}; // x25519-dalek = "2"
fn derive_keys(seed: &[u8; 32]) -> (SigningKey, StaticSecret) {
let hk = Hkdf::<Sha256>::new(Some(b"skald-kdf-v1"), seed);
let mut x = [0u8; 32];
hk.expand(b"x25519", &mut x).unwrap();
let x25519_priv = StaticSecret::from(x); // internal clamping
let mut e = [0u8; 32];
hk.expand(b"ed25519", &mut e).unwrap();
let ed25519_priv = SigningKey::from_bytes(&e);
(ed25519_priv, x25519_priv)
}
// pub keys:
// ed25519_pub = signing_key.verifying_key().to_bytes() // 32B
// x25519_pub = PublicKey::from(&x25519_priv).to_bytes() // 32B
```
### Swift (iOS, CryptoKit)
```swift
import CryptoKit
func deriveKeys(seed: Data) -> (signing: Curve25519.Signing.PrivateKey,
agreement: Curve25519.KeyAgreement.PrivateKey) {
let ikm = SymmetricKey(data: seed)
let salt = Data("skald-kdf-v1".utf8)
let xRaw = HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: salt,
info: Data("x25519".utf8), outputByteCount: 32)
let eRaw = HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: salt,
info: Data("ed25519".utf8), outputByteCount: 32)
let agreement = try! Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: xRaw.withUnsafeBytes { Data($0) })
let signing = try! Curve25519.Signing.PrivateKey(
rawRepresentation: eRaw.withUnsafeBytes { Data($0) })
return (signing, agreement)
}
```
### Kotlin (Android — reference)
Use **BouncyCastle / Tink**: `HKDFBytesGenerator(SHA256Digest)` with the same salt/info, then
`X25519PrivateKeyParameters` and `Ed25519PrivateKeyParameters` from the 32 derived bytes.
---
## 4. ECDH — Key Agreement (X25519, ONLY path)
The agent and each client exchange their **X25519 public key** (the agent via QR; the client via
the pairing frame — see [relay-protocol.md](relay-protocol.md)). The shared secret:
```
shared_secret = X25519(my_x25519_priv, peer_x25519_pub) // 32 bytes
```
It is symmetric: `X25519(a_priv, b_pub) == X25519(b_priv, a_pub)`. **MUST** always and only use
X25519 keys. Ed25519 keys NEVER enter ECDH.
```rust
let shared = my_x25519_priv.diffie_hellman(&PublicKey::from(peer_x25519_pub_bytes));
let shared_secret: [u8; 32] = *shared.as_bytes();
```
```swift
let peerPub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerX25519PubBytes)
let shared = try myAgreementPriv.sharedSecretFromKeyAgreement(with: peerPub)
// `shared` is a SharedSecret; do NOT use it raw: pass through HKDF (§5).
```
> **Point validation.** Standard libraries (x25519-dalek, CryptoKit) handle low-order points;
> an implementation that does not MUST reject an all-zero shared secret.
---
## 5. AEAD Key Derivation (HKDF)
The raw shared secret is never used directly as a key. It is derived:
```
aes_key = HKDF(ikm = shared_secret, salt = SESSION_SALT, info = SESSION_INFO, len = 32)
```
```rust
let hk = Hkdf::<Sha256>::new(Some(b"skald-session-v1"), &shared_secret);
let mut aes_key = [0u8; 32];
hk.expand(b"aes-256-gcm", &mut aes_key).unwrap();
```
```swift
let aesKey = shared.hkdfDerivedSymmetricKey(using: SHA256.self,
salt: Data("skald-session-v1".utf8),
sharedInfo: Data("aes-256-gcm".utf8),
outputByteCount: 32)
```
`aes_key` is **per-peer** (one per agent↔client pair) and static for the life of the pairing
(no PFS in the current protocol).
---
## 6. AEAD — AES-256-GCM with Counter Nonce and AAD (NORMATIVE)
**All** E2E messages are encrypted this way. There is no separate MAC: **GCM is already
authenticated**. (No separate HMAC — it would be redundant and violate key-separation.)
### 6.1 Nonce — Monotonic Counter, NOT Random
The GCM nonce is **12 bytes** and is built deterministically to prevent reuse and provide
**anti-replay**:
```
nonce (12B) = DIR (4B) ‖ counter (8B, big-endian)
```
- `DIR` = `NONCE_DIR_AGENT_TO_CLIENT` if the encryptor is the agent, `NONCE_DIR_CLIENT_TO_AGENT`
if it is the client. Ensures the two directions never collide even though they share `aes_key`.
- `counter` is a **strictly increasing** 64-bit integer, **persisted per-peer and per-direction**.
Starts at `1`. Increments by 1 per sent message. MUST be persisted **before** sending (so a
crash cannot cause reuse).
The **receiver** maintains `last_seen_counter` for (peer, direction) and MUST reject any message
with `counter <= last_seen_counter` (replay or reorder). Under FIFO store-and-forward delivery,
counters arrive in order; a forward gap is allowed (messages lost), a value `<=` is not.
> Consequence: counters are the primary **anti-replay state**. They survive reconnections and
> restarts because they are persisted. If the send counter is irreversibly reset (e.g. seed
> restored without state), a **re-pairing** is required (new `aes_key`, counters reset together).
### 6.2 AAD — Routing Binding
The AAD (Additional Authenticated Data) binds the ciphertext to routing metadata, so a malicious
relay relabelling `from`/`to` causes decryption to **fail**:
```
AAD (96B) = namespace_id_raw (32B) ‖ from_pubkey (32B) ‖ to_pubkey (32B)
```
- `namespace_id_raw` = the 32 raw bytes of the hash from §7 (NOT the hex string).
- `from_pubkey`, `to_pubkey` = **ed25519** public keys (32 raw bytes) of sender and recipient
(same values used for routing in the envelope).
- The receiver reconstructs the AAD from the `from`/`to` fields of the received envelope and its
own `namespace_id`. If they do not match those used in encryption → invalid GCM tag → discard.
### 6.3 Encrypted Block Format
```
sealed = ciphertext ‖ tag(16B) // GCM "combined" output, WITHOUT nonce
```
The `nonce` travels **in plaintext** in a separate envelope field (it is public by definition;
its integrity is guaranteed because GCM uses it as an authenticated IV). On the wire (inside the
E2E JSON payload, before the framing of §… is applied):
```json
{ "nonce": "<hex 24>", "ciphertext": "<base64 of (ciphertext‖tag)>" }
```
In the protobuf transport (`Message` frame) the fields are raw bytes — no hex, no base64.
### 6.4 Rust
```rust
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use aes_gcm::aead::{Aead, Payload};
fn seal(aes_key: &[u8;32], dir: [u8;4], counter: u64,
aad: &[u8;96], plaintext: &[u8]) -> (Vec<u8> /*nonce*/, Vec<u8> /*sealed*/) {
let mut nonce = [0u8; 12];
nonce[..4].copy_from_slice(&dir);
nonce[4..].copy_from_slice(&counter.to_be_bytes());
let cipher = Aes256Gcm::new(aes_key.into());
let sealed = cipher.encrypt(Nonce::from_slice(&nonce),
Payload { msg: plaintext, aad }).expect("encrypt");
(nonce.to_vec(), sealed)
}
fn open(aes_key: &[u8;32], nonce: &[u8;12], aad: &[u8;96], sealed: &[u8]) -> Option<Vec<u8>> {
let cipher = Aes256Gcm::new(aes_key.into());
cipher.decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad }).ok()
}
```
### 6.5 Swift
```swift
func seal(aesKey: SymmetricKey, dir: [UInt8], counter: UInt64,
aad: Data, plaintext: Data) throws -> (nonce: Data, sealed: Data) {
var n = Data(dir) // 4B
var be = counter.bigEndian
n.append(Data(bytes: &be, count: 8)) // +8B = 12B
let nonce = try AES.GCM.Nonce(data: n)
let box = try AES.GCM.seal(plaintext, using: aesKey,
nonce: nonce, authenticating: aad)
// box.ciphertext box.tag == "sealed"
return (n, box.ciphertext + box.tag)
}
func open(aesKey: SymmetricKey, nonce: Data, aad: Data, sealed: Data) throws -> Data {
let ct = sealed.prefix(sealed.count - 16)
let tag = sealed.suffix(16)
let box = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: nonce),
ciphertext: ct, tag: tag)
return try AES.GCM.open(box, using: aesKey, authenticating: aad)
}
```
### 6.6 Static Key Operational Limit
With a static `aes_key` and a 64-bit counter there is no practical risk of nonce exhaustion or
reuse (the counter is unique by construction). The NIST limit for AES-GCM with a single key is
~2³² messages before considering rotation: unreachable for this workload (approvals/clarifications).
Key rotation via **re-pairing** is nevertheless recommended if compromise is suspected.
---
## 7. `namespace_id` Derivation (NORMATIVE)
The namespace id is **immutably bound** to the agent's identity key — preventing takeover without
requiring relay-side state to guarantee it:
```
namespace_id_raw = SHA256( NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub(32B) ) // 32 bytes
namespace_id = hex(namespace_id_raw) // 64 chars
```
- The relay, upon receiving the agent's auth, MUST verify that `namespace_id` derives from the
presented `agent_ed25519_pub` and that the challenge signature is valid under that key.
- The client, from the QR, MUST verify `namespace_id == hex(SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub))`
using the `agent_ed25519_pub` from the QR. This way it does not trust the relay for the id.
- `namespace_id_raw` is also the value used in the AAD (§6.2).
---
## 8. Challenge-Response (Key Ownership Proof)
On WS open the **relay speaks first** and sends a challenge. The connecting peer (any role) signs
and responds. Transport details in [relay-protocol.md](relay-protocol.md); here the primitive.
```
challenge_nonce = 32 random bytes (CSPRNG on the relay side), sent as raw bytes in protobuf
msg_to_sign = AUTH_DOMAIN ‖ 0x00 ‖ challenge_nonce_raw(32B)
signature = Ed25519_sign(ed25519_priv, msg_to_sign) // 64 bytes
```
The relay verifies `Ed25519_verify(pub, signature, msg_to_sign)`. The **domain separation**
(`AUTH_DOMAIN`) prevents an auth signature from being reusable in other contexts.
```rust
let mut msg = Vec::with_capacity(20 + 32);
msg.extend_from_slice(b"skald-relay-auth-v1");
msg.push(0x00);
msg.extend_from_slice(&challenge_nonce_raw); // 32B
let sig = ed25519_priv.sign(&msg); // ed25519-dalek: Signer
```
```swift
var msg = Data("skald-relay-auth-v1".utf8); msg.append(0x00); msg.append(challengeNonceRaw)
let sig = try signingPriv.signature(for: msg) // 64B
```
> Ed25519 internally hashes the message: do **not** pre-hash with SHA-256. Sign
> `AUTH_DOMAIN ‖ 0x00 ‖ nonce` directly.
---
## 9. Pairing Token (Capability Bearer, NOT a Signature)
The `pairing_token` is a **single-use bearer secret**, not a signature:
```
pairing_token = 32 random bytes (CSPRNG on the agent side), as raw bytes in protobuf
```
- The agent generates it on each `pairing_start`, puts it in the QR, and sends it to the relay
(`PairingStart` frame). 256-bit entropy: not guessable.
- The relay treats it as an opaque blob: **byte-for-byte** comparison, **expiry**, **single-use**
(consumed on first successful pairing), valid only while the namespace is in pairing mode.
- The client presents it in the pairing frame. It cannot verify it cryptographically (bearer token):
security comes from **out-of-band QR** + **short TTL** + **single-use** + **explicit agent confirmation**
of the new device.
> No Ed25519 signature on the token: nobody would verify it (security theater). A 256-bit random
> secret is simpler and equally strong as a capability.
---
## 10. Key Storage
### Agent (filesystem + DB)
```
data/relay/
└── seed # 32 bytes, 0600. The only persistent secret.
```
DB table `relay_clients` (see [../plugins/mobile-connector.md](../plugins/mobile-connector.md)):
stores per-client `x25519_pub`, `send_counter`, `recv_counter`. `shared_secret` and `aes_key`
are **never persisted**: re-derived from `seed` + `x25519_pub` on each startup (smaller attack
surface; negligible cost).
### Client (Keychain / Keystore)
- `seed` (32B) with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, shared with the
**Notification Service Extension** via **Keychain Access Group** (the NSE must be able to
derive `aes_key`).
- `namespace_id`, `relay_url`, `agent_ed25519_pub`, `agent_x25519_pub`, `send_counter`,
`recv_counter`: in the same shared storage.
- App uninstall → keys lost → re-pairing required.
---
## 11. Algorithm Summary
| Operation | Algorithm | Input → Output |
|-----------|-----------|----------------|
| Seed | CSPRNG | → 32B |
| Key derivation | HKDF-SHA256 (`KDF_SALT`, info) | seed 32B → x25519_priv 32B, ed25519_priv 32B |
| ECDH | X25519 | my_x25519_priv + peer_x25519_pub → shared 32B |
| AEAD key derivation | HKDF-SHA256 (`SESSION_SALT`, `SESSION_INFO`) | shared 32B → aes_key 32B |
| Encryption | AES-256-GCM | aes_key + nonce(DIR‖counter) + AAD(96B) → ciphertext‖tag |
| `namespace_id` | SHA-256 (`NS_DOMAIN`) | agent_ed25519_pub → 32B (hex) |
| Auth | Ed25519 sign/verify (`AUTH_DOMAIN`) | ed25519_priv + challenge → sig 64B |
| Pairing token | CSPRNG | → 32B single-use bearer |
## 12. Security Considerations
- **PFS**: not in the current protocol. Static `aes_key` → traffic capture + later seed theft =
plaintext for historical messages. Roadmap: ephemeral ECDH per session.
- **Replay**: prevented by monotonic counter (§6.1) + `request_id` idempotency
([payloads.md](payloads.md)) + `ts` freshness.
- **Malicious relay**: cannot read content (E2E) and cannot relabel `from`/`to` (AAD, §6.2);
it can only **drop/hold/reorder** → mitigated by fail-safe + TTL pending on the agent side.
- **Timing**: Ed25519 and AES-GCM are constant-time in the reference implementations
(ed25519-dalek, aes-gcm with AES-NI feature, CryptoKit). Tag/token comparisons MUST be
constant-time (`subtle` / `constantTimeAreEqual`).
- **Input validation**: reject malformed hex/base64, wrong lengths, and every failed decryption
**without** distinguishing the cause in error messages.