wavekat_platform_client/sign.rs
1//! Ed25519 release-attestation signing for public (unauthenticated)
2//! platform endpoints.
3//!
4//! Some platform endpoints run *before* any sign-in — the anonymous
5//! install heartbeat (`POST /api/voice/installs/heartbeat`) is the first.
6//! They carry no bearer token, so without another check anyone could
7//! forge requests (e.g. spray fabricated `installId`s to inflate counts).
8//! This module is the general signing primitive any such endpoint reuses
9//! — it is not heartbeat-specific.
10//!
11//! ## The scheme: a tiny release certificate chain
12//!
13//! There is one offline **release master** Ed25519 keypair. Its private
14//! half lives only in CI secrets and never touches the platform; its
15//! public half is the platform's single root of trust.
16//!
17//! For each release, CI issues a short **certificate**: it generates a
18//! fresh per-version keypair `(priv_v, pub_v)` and signs
19//! `cert = Sign(masterPriv, cert_payload(version, pub_v))`. The build
20//! then ships `priv_v`, `pub_v`, and `cert` (a [`ReleaseCredential`]).
21//!
22//! At request time the build signs a canonical description of the request
23//! with `priv_v`, and sends `version`, `pub_v`, `cert`, and the request
24//! signature in headers. The platform verifies with **only the master
25//! public key**:
26//!
27//! 1. `cert` proves *we* blessed `pub_v` for `version` (so `pub_v` is
28//! trustworthy without the platform holding any secret), and
29//! 2. the request signature proves the caller holds `priv_v`.
30//!
31//! ## Why asymmetric
32//!
33//! The platform stores no secret — only a public key — so a platform
34//! breach cannot forge requests. The master private key never leaves CI.
35//! The honest limit: `priv_v` is inside the distributed binary, so a
36//! determined attacker can extract it. That compromises only *that
37//! version* (not the master, not other versions), and the platform can
38//! revoke it (min-version floor or denylist) without re-signing anything
39//! else. So this buys casual-forgery resistance, blast-radius
40//! containment, and revocability — not unbreakable auth.
41//!
42//! ## Wire layout (scheme version 2) — cross-repo contract
43//!
44//! Mirror these byte layouts exactly on the platform verifier; a change
45//! here is a breaking protocol change and must bump [`SIG_VERSION`].
46//!
47//! Request canonical string the per-version key signs (six newline-joined
48//! lines):
49//!
50//! ```text
51//! WKSIG2
52//! {unix_timestamp_secs}
53//! {nonce_hex}
54//! {METHOD} // upper-case, e.g. POST
55//! {path} // request path, no host, no query
56//! {sha256_hex(body)} // lower-case hex of the exact body bytes sent
57//! ```
58//!
59//! Certificate payload the master key signs (three newline-joined lines):
60//!
61//! ```text
62//! WKCERT2
63//! {version} // the build version this key is issued for
64//! {pub_v_hex} // 64 hex chars (32-byte Ed25519 public key)
65//! ```
66//!
67//! All keys/signatures travel as lower-case hex: keys 64 chars (32 bytes),
68//! signatures 128 chars (64 bytes).
69
70use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
71use sha2::{Digest, Sha256};
72
73use crate::error::{Error, Result};
74
75/// Signature scheme version. Bumping it (and the `WKSIG`/`WKCERT`
76/// prefixes) lets the platform support old and new clients across a
77/// transition.
78pub(crate) const SIG_VERSION: &str = "2";
79
80/// HTTP header carrying the scheme version (`"2"`).
81pub(crate) const HEADER_VERSION: &str = "X-WK-Sig-Ver";
82/// HTTP header carrying the unix-seconds timestamp the signature covers.
83pub(crate) const HEADER_TIMESTAMP: &str = "X-WK-Sig-Ts";
84/// HTTP header carrying the per-request nonce (hex).
85pub(crate) const HEADER_NONCE: &str = "X-WK-Sig-Nonce";
86/// HTTP header carrying the build version the per-version key is bound to.
87pub(crate) const HEADER_BUILD_VERSION: &str = "X-WK-Build-Version";
88/// HTTP header carrying the per-version Ed25519 public key (hex).
89pub(crate) const HEADER_PUBKEY: &str = "X-WK-Pub";
90/// HTTP header carrying the master-signed certificate over the pubkey (hex).
91pub(crate) const HEADER_CERT: &str = "X-WK-Cert";
92/// HTTP header carrying the per-version request signature (hex).
93pub(crate) const HEADER_SIGNATURE: &str = "X-WK-Sig";
94
95/// Everything a release build needs to sign requests: the per-version
96/// private key, its public key, the master-issued certificate over that
97/// public key, and the version the certificate is bound to.
98///
99/// CI produces these per release via [`issue_release_credential`] and
100/// bakes them into the build (the private key as a compile-time secret;
101/// the public key and cert are not sensitive). Construct one from those
102/// baked strings and hand it to [`crate::Client::post_public_signed_json`].
103#[derive(Debug, Clone)]
104pub struct ReleaseCredential {
105 /// 64-char hex of the 32-byte per-version Ed25519 private (seed) key.
106 pub private_key_hex: String,
107 /// 64-char hex of the 32-byte per-version Ed25519 public key.
108 pub public_key_hex: String,
109 /// 128-char hex of the 64-byte master signature over
110 /// [`cert_payload`]`(version, public_key)`.
111 pub cert_hex: String,
112 /// The build version the certificate is bound to. Must match what the
113 /// platform sees; the signer sends it in [`HEADER_BUILD_VERSION`].
114 pub version: String,
115}
116
117/// The freshness/identity values produced when signing a request:
118/// the unix-seconds timestamp and per-request nonce the signature covers,
119/// plus the hex Ed25519 signature itself. Returned by
120/// [`ReleaseCredential::sign_request`]; the caller sends these in the
121/// `X-WK-Sig-Ts` / `X-WK-Sig-Nonce` / `X-WK-Sig` headers.
122#[derive(Debug, Clone)]
123pub struct RequestSignature {
124 pub timestamp: String,
125 pub nonce: String,
126 pub signature_hex: String,
127}
128
129/// Lower-case hex SHA-256 of `bytes`.
130pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
131 let mut hasher = Sha256::new();
132 hasher.update(bytes);
133 hex::encode(hasher.finalize())
134}
135
136/// The canonical string a per-version key signs for a request. Kept
137/// standalone so tests pin the exact byte layout the platform mirrors.
138pub(crate) fn canonical_request(
139 timestamp: &str,
140 nonce: &str,
141 method: &str,
142 path: &str,
143 body_hash_hex: &str,
144) -> String {
145 format!("WKSIG{SIG_VERSION}\n{timestamp}\n{nonce}\n{method}\n{path}\n{body_hash_hex}")
146}
147
148/// The certificate payload the master key signs to bless a per-version
149/// public key. Binding the version in means a cert can't be reused to
150/// vouch for a key under a different (e.g. un-revoked) version.
151pub fn cert_payload(version: &str, public_key_hex: &str) -> String {
152 format!("WKCERT{SIG_VERSION}\n{version}\n{public_key_hex}")
153}
154
155/// Current unix time in whole seconds. Falls back to 0 if the clock is
156/// before the epoch — the platform's freshness window then rejects it,
157/// which is the safe outcome.
158pub(crate) fn unix_secs() -> u64 {
159 std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .map(|d| d.as_secs())
162 .unwrap_or(0)
163}
164
165/// 16 random bytes as lower-case hex (32 chars) for the per-request nonce.
166pub(crate) fn random_nonce() -> String {
167 use rand::RngCore;
168 let mut buf = [0u8; 16];
169 rand::thread_rng().fill_bytes(&mut buf);
170 hex::encode(buf)
171}
172
173/// Decode a hex string into exactly `N` bytes, mapping any error to a
174/// `BadRequest` with `what` for context.
175fn hex_to_array<const N: usize>(hex_str: &str, what: &str) -> Result<[u8; N]> {
176 let bytes = hex::decode(hex_str.trim())
177 .map_err(|e| Error::BadRequest(format!("{what}: invalid hex: {e}")))?;
178 bytes
179 .try_into()
180 .map_err(|_| Error::BadRequest(format!("{what}: expected {N} bytes")))
181}
182
183impl ReleaseCredential {
184 /// Parse the per-version private key, returning the dalek signing key.
185 fn signing_key(&self) -> Result<SigningKey> {
186 let seed = hex_to_array::<32>(&self.private_key_hex, "release private key")?;
187 Ok(SigningKey::from_bytes(&seed))
188 }
189
190 /// Sign a request's canonical string with the per-version key,
191 /// producing the timestamp/nonce/signature to send. `body` is the
192 /// exact bytes that will be sent as the request body; `method` is the
193 /// upper-case HTTP verb and `path` the request path (no host, no
194 /// query). [`Client::post_public_signed_json`] wraps this for the
195 /// common JSON-POST case.
196 pub fn sign_request(&self, method: &str, path: &str, body: &[u8]) -> Result<RequestSignature> {
197 let signing_key = self.signing_key()?;
198 let timestamp = unix_secs().to_string();
199 let nonce = random_nonce();
200 let body_hash = sha256_hex(body);
201 let message = canonical_request(×tamp, &nonce, method, path, &body_hash);
202 let sig: Signature = signing_key.sign(message.as_bytes());
203 Ok(RequestSignature {
204 timestamp,
205 nonce,
206 signature_hex: hex::encode(sig.to_bytes()),
207 })
208 }
209}
210
211// ---- Release-key issuance (CI / tooling side) -----------------------------
212//
213// These run offline in CI, not in the daemon, but live here so the byte
214// formats they emit are guaranteed to match what the signer above and the
215// platform verifier consume. The `release_keys` example wraps them in a
216// CLI.
217
218/// A freshly generated Ed25519 keypair as hex `(private_hex, public_hex)`.
219pub fn generate_keypair() -> (String, String) {
220 use rand::RngCore;
221 let mut seed = [0u8; 32];
222 rand::thread_rng().fill_bytes(&mut seed);
223 let signing_key = SigningKey::from_bytes(&seed);
224 let verifying_key = signing_key.verifying_key();
225 (hex::encode(seed), hex::encode(verifying_key.to_bytes()))
226}
227
228/// Generate the offline release master keypair. Identical to
229/// [`generate_keypair`] — named for intent at the call site (the master
230/// is generated once, by hand, and its private half guarded as a CI
231/// secret).
232pub fn generate_master() -> (String, String) {
233 generate_keypair()
234}
235
236/// Issue a [`ReleaseCredential`] for `version`: generate a per-version
237/// keypair and sign its certificate with the master private key
238/// (`master_private_key_hex`). Run by CI at release time.
239pub fn issue_release_credential(
240 master_private_key_hex: &str,
241 version: &str,
242) -> Result<ReleaseCredential> {
243 let master_seed = hex_to_array::<32>(master_private_key_hex, "master private key")?;
244 let master = SigningKey::from_bytes(&master_seed);
245 let (private_key_hex, public_key_hex) = generate_keypair();
246 let payload = cert_payload(version, &public_key_hex);
247 let cert: Signature = master.sign(payload.as_bytes());
248 Ok(ReleaseCredential {
249 private_key_hex,
250 public_key_hex,
251 cert_hex: hex::encode(cert.to_bytes()),
252 version: version.to_string(),
253 })
254}
255
256// ---- Verification helpers (mirrors the platform; used by tests) -----------
257
258/// Verify that `cert_hex` is a valid master signature over
259/// `cert_payload(version, public_key_hex)`. The platform performs the
260/// equivalent check in TypeScript; this Rust copy backs the round-trip
261/// tests and documents the exact bytes.
262pub fn verify_cert(
263 master_public_key_hex: &str,
264 version: &str,
265 public_key_hex: &str,
266 cert_hex: &str,
267) -> Result<bool> {
268 let master_pub = hex_to_array::<32>(master_public_key_hex, "master public key")?;
269 let master = VerifyingKey::from_bytes(&master_pub)
270 .map_err(|e| Error::BadRequest(format!("master public key: {e}")))?;
271 let cert_bytes = hex_to_array::<64>(cert_hex, "certificate")?;
272 let cert = Signature::from_bytes(&cert_bytes);
273 let payload = cert_payload(version, public_key_hex);
274 Ok(master.verify(payload.as_bytes(), &cert).is_ok())
275}
276
277/// Verify a request signature against a per-version public key.
278pub fn verify_request(
279 public_key_hex: &str,
280 timestamp: &str,
281 nonce: &str,
282 method: &str,
283 path: &str,
284 body: &[u8],
285 signature_hex: &str,
286) -> Result<bool> {
287 let pub_bytes = hex_to_array::<32>(public_key_hex, "public key")?;
288 let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
289 .map_err(|e| Error::BadRequest(format!("public key: {e}")))?;
290 let sig_bytes = hex_to_array::<64>(signature_hex, "signature")?;
291 let sig = Signature::from_bytes(&sig_bytes);
292 let body_hash = sha256_hex(body);
293 let message = canonical_request(timestamp, nonce, method, path, &body_hash);
294 Ok(verifying_key.verify(message.as_bytes(), &sig).is_ok())
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn canonical_request_layout_is_stable() {
303 // Cross-repo wire contract — the platform verifier re-derives this
304 // exact six-line layout.
305 let s = canonical_request(
306 "1700000000",
307 "deadbeef",
308 "POST",
309 "/api/voice/installs/heartbeat",
310 "abc123",
311 );
312 assert_eq!(
313 s,
314 "WKSIG2\n1700000000\ndeadbeef\nPOST\n/api/voice/installs/heartbeat\nabc123"
315 );
316 }
317
318 #[test]
319 fn cert_payload_layout_is_stable() {
320 let s = cert_payload("0.0.22", "ab12");
321 assert_eq!(s, "WKCERT2\n0.0.22\nab12");
322 }
323
324 #[test]
325 fn sha256_hex_matches_known_vector() {
326 assert_eq!(
327 sha256_hex(b""),
328 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
329 );
330 }
331
332 #[test]
333 fn generate_keypair_yields_distinct_32_byte_keys() {
334 let (priv_a, pub_a) = generate_keypair();
335 let (priv_b, _pub_b) = generate_keypair();
336 assert_eq!(priv_a.len(), 64, "32-byte private → 64 hex chars");
337 assert_eq!(pub_a.len(), 64, "32-byte public → 64 hex chars");
338 assert_ne!(priv_a, priv_b, "fresh keypairs must differ");
339 }
340
341 #[test]
342 fn full_chain_round_trips() {
343 // The whole flow the way CI + a build + the platform run it.
344 let (master_priv, master_pub) = generate_master();
345 let cred = issue_release_credential(&master_priv, "0.0.22").unwrap();
346
347 // Platform step 1: the cert blesses pub_v for this version.
348 assert!(
349 verify_cert(&master_pub, "0.0.22", &cred.public_key_hex, &cred.cert_hex).unwrap(),
350 "valid cert must verify under the master public key"
351 );
352
353 // Build signs a request; platform step 2 verifies it with pub_v.
354 let body = br#"{"installId":"x"}"#;
355 let rs = cred
356 .sign_request("POST", "/api/voice/installs/heartbeat", body)
357 .unwrap();
358 assert!(
359 verify_request(
360 &cred.public_key_hex,
361 &rs.timestamp,
362 &rs.nonce,
363 "POST",
364 "/api/voice/installs/heartbeat",
365 body,
366 &rs.signature_hex,
367 )
368 .unwrap(),
369 "a freshly signed request must verify under its pub_v"
370 );
371 }
372
373 #[test]
374 fn cert_is_rejected_under_the_wrong_master() {
375 let (master_priv, _master_pub) = generate_master();
376 let (_other_priv, other_pub) = generate_master();
377 let cred = issue_release_credential(&master_priv, "0.0.22").unwrap();
378 // A different master's public key must not validate this cert.
379 assert!(
380 !verify_cert(&other_pub, "0.0.22", &cred.public_key_hex, &cred.cert_hex).unwrap(),
381 "cert must not verify under a foreign master key"
382 );
383 }
384
385 #[test]
386 fn cert_is_bound_to_its_version() {
387 let (master_priv, master_pub) = generate_master();
388 let cred = issue_release_credential(&master_priv, "0.0.22").unwrap();
389 // Same pub key, different claimed version → cert no longer matches,
390 // so an attacker can't reuse a cert to dodge a version denylist.
391 assert!(
392 !verify_cert(&master_pub, "0.0.99", &cred.public_key_hex, &cred.cert_hex).unwrap(),
393 "cert must not verify for a different version"
394 );
395 }
396
397 #[test]
398 fn tampered_request_body_fails_verification() {
399 let (master_priv, _master_pub) = generate_master();
400 let cred = issue_release_credential(&master_priv, "0.0.22").unwrap();
401 let rs = cred.sign_request("POST", "/p", b"original").unwrap();
402 assert!(
403 !verify_request(
404 &cred.public_key_hex,
405 &rs.timestamp,
406 &rs.nonce,
407 "POST",
408 "/p",
409 b"tampered",
410 &rs.signature_hex,
411 )
412 .unwrap(),
413 "a body that differs from the signed one must fail"
414 );
415 }
416
417 #[test]
418 fn request_signed_by_one_version_fails_under_another_pubkey() {
419 let (master_priv, _master_pub) = generate_master();
420 let cred_a = issue_release_credential(&master_priv, "0.0.22").unwrap();
421 let cred_b = issue_release_credential(&master_priv, "0.0.23").unwrap();
422 let rs = cred_a.sign_request("POST", "/p", b"body").unwrap();
423 // Verifying A's signature with B's public key must fail.
424 assert!(
425 !verify_request(
426 &cred_b.public_key_hex,
427 &rs.timestamp,
428 &rs.nonce,
429 "POST",
430 "/p",
431 b"body",
432 &rs.signature_hex,
433 )
434 .unwrap(),
435 "a signature must only verify under its own pub_v"
436 );
437 }
438}