Skip to main content

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(&timestamp, &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}