14 KiB
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 extractplaintext[0](version),plaintext[1](comp), and the payload.
Constants and encoding: crypto.md §1, index.md §5.
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) — what is fed to AES-GCM for V14 |
258B, see §3 | |
| PT_FRAMED_C2A | 0x01 ‖ 0x00 ‖ PLAINTEXT_C2A (framing.md §1) — 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.
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).
// 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_strictMUST accept signatures produced by the iOS client. Verified by cross-compat tests incrates/skald-relay-server/src/auth.rs::tests::challenge_verifies_cryptokit_signatureandSkaldInboxTests/SkaldInboxTests.swift::testAuthSignatureCrossCompatWithDalek.
5. Swift Verification (CryptoKit)
Unit test in the app: derive from SEED_AGENT/SEED_CLIENT and assert equality with §4.
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
sealedproduced by the Rust plugin and vice versa.