18 KiB
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 before integrating.
Field encoding: see index.md §5. 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_KDF_SALT |
"skald-pipe-v1" |
HKDF salt: ephemeral ECDH → per-pipe AES key (pipe.md §4) |
PIPE_KDF_INFO |
"pipe-aes-256-gcm" |
HKDF info, per-pipe AES key (pipe.md §4) |
NONCE_DIR_PIPE_INITIATOR |
0x00 0x00 0x00 0x03 |
Nonce prefix, pipe initiator→responder (pipe.md §4) |
NONCE_DIR_PIPE_RESPONDER |
0x00 0x00 0x00 0x04 |
Nonce prefix, pipe responder→initiator (pipe.md §4) |
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) reuses this entire suite — X25519 ECDH, HKDF, AES-256-GCM with the
DIR ‖ counternonce (§6) — keyed by a per-pipe ephemeral DH (Perfect Forward Secrecy), withaad = 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, permissions0600. 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.SigningvsCurve25519.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_privabove). 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)
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)
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). 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.
let shared = my_x25519_priv.diffie_hellman(&PublicKey::from(peer_x25519_pub_bytes));
let shared_secret: [u8; 32] = *shared.as_bytes();
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)
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();
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_CLIENTif the encryptor is the agent,NONCE_DIR_CLIENT_TO_AGENTif it is the client. Ensures the two directions never collide even though they shareaes_key.counteris a strictly increasing 64-bit integer, persisted per-peer and per-direction. Starts at1. 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/tofields of the received envelope and its ownnamespace_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):
{ "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
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
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_idderives from the presentedagent_ed25519_puband 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 theagent_ed25519_pubfrom the QR. This way it does not trust the relay for the id. namespace_id_rawis 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; 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.
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
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 ‖ noncedirectly.
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 (PairingStartframe). 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):
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) withkSecAttrAccessibleWhenUnlockedThisDeviceOnly, shared with the Notification Service Extension via Keychain Access Group (the NSE must be able to deriveaes_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_ididempotency (payloads.md) +tsfreshness. - 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.