diff --git a/Cargo.lock b/Cargo.lock index 3d3e24d..0844243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -120,6 +126,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bitflags" version = "1.3.2" @@ -270,6 +282,12 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -339,6 +357,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -373,6 +403,33 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "darling" version = "0.20.11" @@ -408,6 +465,16 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -486,6 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common", "subtle", ] @@ -531,12 +599,55 @@ dependencies = [ "dtoa", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "elsa" version = "1.11.2" @@ -669,6 +780,22 @@ dependencies = [ "log", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.4" @@ -809,6 +936,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -834,6 +962,17 @@ dependencies = [ "wasip2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "handlebars" version = "6.3.2" @@ -1077,6 +1216,42 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + [[package]] name = "js-sys" version = "0.3.82" @@ -1103,6 +1278,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1110,6 +1288,12 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1194,12 +1378,48 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -1222,6 +1442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1246,6 +1467,26 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1388,6 +1629,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1414,12 +1676,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1472,6 +1752,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -1534,6 +1826,26 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rsa" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -1590,6 +1902,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.32.0" @@ -1741,6 +2066,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -1765,6 +2100,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2012,6 +2363,7 @@ dependencies = [ "config", "cookie", "derive_more", + "ed25519-dalek", "error-stack", "fastly", "flate2", @@ -2020,10 +2372,12 @@ dependencies = [ "hex", "hmac", "http", + "jose-jwk", "log", "log-fastly", "lol_html", "pin-project-lite", + "rand", "regex", "serde", "serde_json", @@ -2385,6 +2739,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -2411,6 +2785,9 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 128bed8..44b723c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ chrono = "0.4.42" config = "0.15.18" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } +ed25519-dalek = { version = "2.1", features = ["rand_core"] } error-stack = "0.6" fastly = "0.11.9" fern = "0.7.1" @@ -38,6 +39,8 @@ log-fastly = "0.11.9" lol_html = "2.7.0" pin-project-lite = "0.2" regex = "1.12.2" +jose-jwk = "0.1.2" +rand = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.10.9" diff --git a/README.md b/README.md index 7166c0a..d39abc1 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,10 @@ git clone git@github.com:IABTechLab/trusted-server.git ### Configure #### Edit configuration files -:information_source: Note that you’ll have to edit the following files for your setup: +:information_source: Note that you'll have to edit the following files for your setup: -- fastly.toml (service ID, author, description) -- trusted-server.toml (KV store ID names - optional) +- fastly.toml (service ID, author, description, Config/Secret Store IDs for request signing) +- trusted-server.toml (KV store ID names - optional, request signing configuration) ### Build @@ -153,6 +153,47 @@ cargo test - `cargo check`: Ensure compilation succeeds on Linux, MacOS, Windows and WebAssembly - `cargo bench`: Run all benchmarks +## Request Signing + +Trusted Server supports cryptographic signing of OpenRTB requests and other API calls using Ed25519 keys. + +### Configuration + +Request signing requires Fastly Config Store and Secret Store for key management: + +1. **Create Fastly Stores** (via Fastly Control Panel or CLI): + - Config Store: `jwks_store` - stores public keys (JWKs) and key metadata + - Secret Store: `signing_keys` - stores private signing keys + +2. **Configure in trusted-server.toml**: +```toml +[request_signing] +enabled = true # Set to true to enable request signing +config_store_id = "" # Config Store ID from Fastly +secret_store_id = "" # Secret Store ID from Fastly +``` + +### Key Management Endpoints + +Once configured, the following endpoints are available: + +- **`GET /.well-known/ts.jwks.json`**: Returns active public keys in JWKS format for signature verification +- **`POST /verify-signature`**: Verifies a signature against a payload and key ID (useful for testing) + - Request body: `{"payload": "...", "signature": "...", "kid": "..."}` + - Response: `{"verified": true/false, "kid": "...", "message": "..."}` + +#### Admin Endpoints (Key Rotation) + +- **`POST /admin/keys/rotate`**: Generates and activates a new signing key + - Optional body: `{"kid": "custom-key-id"}` (auto-generates date-based ID if omitted) + - Response includes new key ID, previous key ID, and active keys list + +- **`POST /admin/keys/deactivate`**: Deactivates or deletes a key + - Request body: `{"kid": "key-to-deactivate", "delete": false}` + - Set `delete: true` to permanently remove the key (also deactivates it) + +:warning: Key rotation keeps both the new and previous key active to allow for graceful transitions. Deactivate old keys manually when no longer needed. + ## First-Party Endpoints - `/first-party/ad` (GET): returns HTML for a single slot (`slot`, `w`, `h` query params). The server inspects returned creative HTML and rewrites: diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 8261ca0..cdf21e3 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -25,7 +25,9 @@ handlebars = { workspace = true } hex = { workspace = true } hmac = { workspace = true } http = { workspace = true } +jose-jwk = { workspace = true } log = { workspace = true } +rand = { workspace = true } log-fastly = { workspace = true } lol_html = { workspace = true } pin-project-lite = { workspace = true } @@ -39,6 +41,7 @@ url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } validator = { workspace = true } +ed25519-dalek = { workspace = true } [build-dependencies] config = { workspace = true } diff --git a/crates/common/src/fastly_storage.rs b/crates/common/src/fastly_storage.rs new file mode 100644 index 0000000..c4a8241 --- /dev/null +++ b/crates/common/src/fastly_storage.rs @@ -0,0 +1,360 @@ +use std::io::Read; + +use fastly::{ConfigStore, Request, Response, SecretStore}; +use http::StatusCode; + +use crate::backend::ensure_backend_from_url; +use crate::error::TrustedServerError; + +pub struct FastlyConfigStore { + store_name: String, +} + +impl FastlyConfigStore { + pub fn new(store_name: impl Into) -> Self { + Self { + store_name: store_name.into(), + } + } + + pub fn get(&self, key: &str) -> Result { + // TODO use try_open and return the error + let store = ConfigStore::open(&self.store_name); + store + .get(key) + .ok_or_else(|| TrustedServerError::Configuration { + message: format!( + "Key '{}' not found in config store '{}'", + key, self.store_name + ), + }) + } +} + +pub struct FastlySecretStore { + store_name: String, +} + +impl FastlySecretStore { + pub fn new(store_name: impl Into) -> Self { + Self { + store_name: store_name.into(), + } + } + + pub fn get(&self, key: &str) -> Result, TrustedServerError> { + let store = + SecretStore::open(&self.store_name).map_err(|_| TrustedServerError::Configuration { + message: format!("Failed to open SecretStore '{}'", self.store_name), + })?; + + let secret = store + .get(key) + .ok_or_else(|| TrustedServerError::Configuration { + message: format!( + "Secret '{}' not found in secret store '{}'", + key, self.store_name + ), + })?; + + secret + .try_plaintext() + .map_err(|_| TrustedServerError::Configuration { + message: "Failed to get secret plaintext".into(), + }) + .map(|bytes| bytes.into_iter().collect()) + } + + pub fn get_string(&self, key: &str) -> Result { + let bytes = self.get(key)?; + String::from_utf8(bytes).map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to decode secret as UTF-8: {}", e), + }) + } +} + +pub struct FastlyApiClient { + api_key: Vec, + base_url: String, +} + +impl FastlyApiClient { + pub fn new() -> Result { + Self::from_secret_store("api-keys", "api_key") + } + + pub fn from_secret_store(store_name: &str, key_name: &str) -> Result { + ensure_backend_from_url("https://api.fastly.com").map_err(|e| { + TrustedServerError::Configuration { + message: format!("Failed to ensure API backend: {}", e), + } + })?; + + let secret_store = FastlySecretStore::new(store_name); + let api_key = secret_store.get(key_name)?; + + Ok(Self { + api_key, + base_url: "https://api.fastly.com".to_string(), + }) + } + + fn make_request( + &self, + method: &str, + path: &str, + body: Option, + content_type: &str, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + + let api_key_str = String::from_utf8_lossy(&self.api_key).to_string(); + + let mut request = match method { + "GET" => Request::get(&url), + "POST" => Request::post(&url), + "PUT" => Request::put(&url), + "DELETE" => Request::delete(&url), + _ => { + return Err(TrustedServerError::Configuration { + message: format!("Unsupported HTTP method: {}", method), + }) + } + }; + + request = request + .with_header("Fastly-Key", api_key_str) + .with_header("Accept", "application/json"); + + if let Some(body_content) = body { + request = request + .with_header("Content-Type", content_type) + .with_body(body_content); + } + + request.send("backend_https_api_fastly_com").map_err(|e| { + TrustedServerError::Configuration { + message: format!("Failed to send API request: {}", e), + } + }) + } + + pub fn update_config_item( + &self, + store_id: &str, + key: &str, + value: &str, + ) -> Result<(), TrustedServerError> { + let path = format!("/resources/stores/config/{}/item/{}", store_id, key); + let payload = format!("item_value={}", value); + + let mut response = self.make_request( + "PUT", + &path, + Some(payload), + "application/x-www-form-urlencoded", + )?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to read API response: {}", e), + })?; + + if response.get_status() == StatusCode::OK { + Ok(()) + } else { + Err(TrustedServerError::Configuration { + message: format!( + "Failed to update config item: HTTP {} - {}", + response.get_status(), + buf + ), + }) + } + } + + pub fn create_secret( + &self, + store_id: &str, + secret_name: &str, + secret_value: &str, + ) -> Result<(), TrustedServerError> { + let path = format!("/resources/stores/secret/{}/secrets", store_id); + + let payload = serde_json::json!({ + "name": secret_name, + "secret": secret_value + }); + + let mut response = + self.make_request("POST", &path, Some(payload.to_string()), "application/json")?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to read API response: {}", e), + })?; + + if response.get_status() == StatusCode::OK { + Ok(()) + } else { + Err(TrustedServerError::Configuration { + message: format!( + "Failed to create secret: HTTP {} - {}", + response.get_status(), + buf + ), + }) + } + } + + pub fn delete_config_item(&self, store_id: &str, key: &str) -> Result<(), TrustedServerError> { + let path = format!("/resources/stores/config/{}/item/{}", store_id, key); + + let mut response = self.make_request("DELETE", &path, None, "application/json")?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to read API response: {}", e), + })?; + + if response.get_status() == StatusCode::OK + || response.get_status() == StatusCode::NO_CONTENT + { + Ok(()) + } else { + Err(TrustedServerError::Configuration { + message: format!( + "Failed to delete config item: HTTP {} - {}", + response.get_status(), + buf + ), + }) + } + } + + pub fn delete_secret( + &self, + store_id: &str, + secret_name: &str, + ) -> Result<(), TrustedServerError> { + let path = format!( + "/resources/stores/secret/{}/secrets/{}", + store_id, secret_name + ); + + let mut response = self.make_request("DELETE", &path, None, "application/json")?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to read API response: {}", e), + })?; + + if response.get_status() == StatusCode::OK + || response.get_status() == StatusCode::NO_CONTENT + { + Ok(()) + } else { + Err(TrustedServerError::Configuration { + message: format!( + "Failed to delete secret: HTTP {} - {}", + response.get_status(), + buf + ), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_store_new() { + let store = FastlyConfigStore::new("test_store"); + assert_eq!(store.store_name, "test_store"); + } + + #[test] + fn test_secret_store_new() { + let store = FastlySecretStore::new("test_secrets"); + assert_eq!(store.store_name, "test_secrets"); + } + + #[test] + fn test_config_store_get() { + let store = FastlyConfigStore::new("jwks_store"); + let result = store.get("current-kid"); + match result { + Ok(kid) => println!("Current KID: {}", kid), + Err(e) => println!("Expected error in test environment: {}", e), + } + } + + #[test] + fn test_secret_store_get() { + let store = FastlySecretStore::new("signing_keys"); + let config_store = FastlyConfigStore::new("jwks_store"); + + match config_store.get("current-kid") { + Ok(kid) => match store.get(&kid) { + Ok(bytes) => { + println!("Successfully loaded secret, {} bytes", bytes.len()); + assert!(!bytes.is_empty()); + } + Err(e) => println!("Error loading secret: {}", e), + }, + Err(e) => println!("Error getting current kid: {}", e), + } + } + + #[test] + fn test_api_client_creation() { + let result = FastlyApiClient::new(); + match result { + Ok(_client) => println!("Successfully created API client"), + Err(e) => println!("Expected error in test environment: {}", e), + } + } + + #[test] + fn test_update_config_item() { + let result = FastlyApiClient::new(); + if let Ok(client) = result { + let result = + client.update_config_item("5WNlRjznCUAGTU0QeYU8x2", "test-key", "test-value"); + match result { + Ok(()) => println!("Successfully updated config item"), + Err(e) => println!("Failed to update config item: {}", e), + } + } + } + + #[test] + fn test_create_secret() { + let result = FastlyApiClient::new(); + if let Ok(client) = result { + let result = client.create_secret( + "Ltf3CkSGV0Yn2PIC2lDcZx", + "test-secret-new", + "SGVsbG8sIHdvcmxkIQ==", + ); + match result { + Ok(()) => println!("Successfully created secret"), + Err(e) => println!("Failed to create secret: {}", e), + } + } + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f48048b..124b839 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -30,6 +30,7 @@ pub mod constants; pub mod cookies; pub mod creative; pub mod error; +pub mod fastly_storage; pub mod geo; pub mod html_processor; pub mod http_util; @@ -38,6 +39,7 @@ pub mod openrtb; pub mod prebid_proxy; pub mod proxy; pub mod publisher; +pub mod request_signing; pub mod settings; pub mod settings_data; pub mod streaming_processor; diff --git a/crates/common/src/prebid_proxy.rs b/crates/common/src/prebid_proxy.rs index f94079c..748e8b8 100644 --- a/crates/common/src/prebid_proxy.rs +++ b/crates/common/src/prebid_proxy.rs @@ -13,6 +13,7 @@ use crate::backend::ensure_backend_from_url; use crate::constants::{HEADER_SYNTHETIC_FRESH, HEADER_SYNTHETIC_TRUSTED_SERVER}; use crate::error::TrustedServerError; use crate::geo::GeoInfo; +use crate::request_signing::RequestSigner; use crate::settings::Settings; use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; @@ -185,6 +186,28 @@ fn enhance_openrtb_request( }); } + // Add trusted server signature (if enabled) + if let Some(request_signing_config) = &settings.request_signing { + if request_signing_config.enabled && request["id"].is_string() { + log::info!("signing openrtb request..."); + if !request["ext"].is_object() { + request["ext"] = json!({}); + } + + let id = request["id"] + .as_str() + .expect("as_str guaranteed by is_string check"); + + let signer = RequestSigner::from_config()?; + let signature = signer.sign(id.as_bytes())?; + + request["ext"]["trusted_server"] = json!({ + "signature": signature, + "kid": signer.kid + }); + } + } + Ok(()) } diff --git a/crates/common/src/request_signing/endpoints.rs b/crates/common/src/request_signing/endpoints.rs new file mode 100644 index 0000000..9bd5132 --- /dev/null +++ b/crates/common/src/request_signing/endpoints.rs @@ -0,0 +1,517 @@ +//! HTTP endpoint handlers for request signing operations. +//! +//! This module provides endpoint handlers for JWKS retrieval, signature verification, +//! key rotation, and key deactivation operations. + +use error_stack::{Report, ResultExt}; +use fastly::{Request, Response}; +use serde::{Deserialize, Serialize}; + +use crate::error::TrustedServerError; +use crate::request_signing::rotation::KeyRotationManager; +use crate::request_signing::signing; +use crate::settings::Settings; + +/// Retrieves and returns active jwks public keys. +pub fn handle_jwks_endpoint( + _settings: &Settings, + _req: Request, +) -> Result> { + let jwks_json = crate::request_signing::jwks::get_active_jwks().change_context( + TrustedServerError::Configuration { + message: "Failed to retrieve JWKS".into(), + }, + )?; + + Ok(Response::from_status(200) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body_text_plain(&jwks_json)) +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct VerifySignatureRequest { + pub payload: String, + pub signature: String, + pub kid: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct VerifySignatureResponse { + pub verified: bool, + pub kid: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Will verify a signature given a payload and kid +/// Useful for testing integration with signatures +pub fn handle_verify_signature( + _settings: &Settings, + mut req: Request, +) -> Result> { + let body = req.take_body_str(); + let verify_req: VerifySignatureRequest = + serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + message: "Invalid JSON request body".into(), + })?; + + let verification_result = signing::verify_signature( + verify_req.payload.as_bytes(), + &verify_req.signature, + &verify_req.kid, + ); + + let response = match verification_result { + Ok(true) => VerifySignatureResponse { + verified: true, + kid: verify_req.kid, + message: "Signature verified successfully".into(), + error: None, + }, + Ok(false) => VerifySignatureResponse { + verified: false, + kid: verify_req.kid, + message: "Signature verification failed".into(), + error: Some("Invalid signature".into()), + }, + Err(e) => VerifySignatureResponse { + verified: false, + kid: verify_req.kid, + message: "Verification error".into(), + error: Some(format!("{}", e)), + }, + }; + + let response_json = serde_json::to_string(&response).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize response: {}", e), + }) + })?; + + Ok(Response::from_status(200) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body_text_plain(&response_json)) +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RotateKeyRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RotateKeyResponse { + pub success: bool, + pub message: String, + pub new_kid: String, + pub previous_kid: Option, + pub active_kids: Vec, + pub jwk: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Rotates the current active kid by generating and saving a new one +pub fn handle_rotate_key( + settings: &Settings, + mut req: Request, +) -> Result> { + let (config_store_id, secret_store_id) = match &settings.request_signing { + Some(setting) => (&setting.config_store_id, &setting.secret_store_id), + None => { + return Err(TrustedServerError::Configuration { + message: "Missing signing storage configuration.".to_string(), + } + .into()); + } + }; + + let body = req.take_body_str(); + let rotate_req: RotateKeyRequest = if body.is_empty() { + RotateKeyRequest { kid: None } + } else { + serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + message: "Invalid JSON request body".into(), + })? + }; + + let manager = KeyRotationManager::new(config_store_id, secret_store_id).change_context( + TrustedServerError::Configuration { + message: "Failed to create KeyRotationManager".into(), + }, + )?; + + match manager.rotate_key(rotate_req.kid) { + Ok(result) => { + let jwk_value = serde_json::to_value(&result.jwk).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize JWK: {}", e), + }) + })?; + + let response = RotateKeyResponse { + success: true, + message: "Key rotated successfully".to_string(), + new_kid: result.new_kid, + previous_kid: result.previous_kid, + active_kids: result.active_kids, + jwk: jwk_value, + error: None, + }; + + let response_json = serde_json::to_string(&response).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize response: {}", e), + }) + })?; + + Ok(Response::from_status(200) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body_text_plain(&response_json)) + } + Err(e) => { + let response = RotateKeyResponse { + success: false, + message: "Key rotation failed".to_string(), + new_kid: String::new(), + previous_kid: None, + active_kids: vec![], + jwk: serde_json::json!({}), + error: Some(format!("{}", e)), + }; + + let response_json = serde_json::to_string(&response).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize response: {}", e), + }) + })?; + + Ok(Response::from_status(500) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body_text_plain(&response_json)) + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct DeactivateKeyRequest { + pub kid: String, + #[serde(default)] + pub delete: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct DeactivateKeyResponse { + pub success: bool, + pub message: String, + pub deactivated_kid: String, + pub deleted: bool, + pub remaining_active_kids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Deactivates an active key +pub fn handle_deactivate_key( + settings: &Settings, + mut req: Request, +) -> Result> { + let (config_store_id, secret_store_id) = match &settings.request_signing { + Some(setting) => (&setting.config_store_id, &setting.secret_store_id), + None => { + return Err(TrustedServerError::Configuration { + message: "Missing signing storage configuration.".to_string(), + } + .into()); + } + }; + + let body = req.take_body_str(); + let deactivate_req: DeactivateKeyRequest = + serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + message: "Invalid JSON request body".into(), + })?; + + let manager = KeyRotationManager::new(config_store_id, secret_store_id).change_context( + TrustedServerError::Configuration { + message: "Failed to create KeyRotationManager".into(), + }, + )?; + + let result = if deactivate_req.delete { + manager.delete_key(&deactivate_req.kid) + } else { + manager.deactivate_key(&deactivate_req.kid) + }; + + match result { + Ok(()) => { + let remaining_keys = manager.list_active_keys().unwrap_or_else(|_| vec![]); + + let response = DeactivateKeyResponse { + success: true, + message: if deactivate_req.delete { + "Key deleted successfully".to_string() + } else { + "Key deactivated successfully".to_string() + }, + deactivated_kid: deactivate_req.kid, + deleted: deactivate_req.delete, + remaining_active_kids: remaining_keys, + error: None, + }; + + let response_json = serde_json::to_string(&response).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize response: {}", e), + }) + })?; + + Ok(Response::from_status(200) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body_text_plain(&response_json)) + } + Err(e) => { + let response = DeactivateKeyResponse { + success: false, + message: if deactivate_req.delete { + "Key deletion failed".to_string() + } else { + "Key deactivation failed".to_string() + }, + deactivated_kid: deactivate_req.kid.clone(), + deleted: false, + remaining_active_kids: vec![], + error: Some(format!("{}", e)), + }; + + let response_json = serde_json::to_string(&response).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("Failed to serialize response: {}", e), + }) + })?; + + Ok(Response::from_status(500) + .with_content_type(fastly::mime::APPLICATION_JSON) + .with_body_text_plain(&response_json)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fastly::http::{Method, StatusCode}; + + #[test] + fn test_handle_verify_signature_valid() { + let settings = crate::test_support::tests::create_test_settings(); + + // First, create a valid signature + let payload = "test message"; + let signer = crate::request_signing::RequestSigner::from_config().unwrap(); + let signature = signer.sign(payload.as_bytes()).unwrap(); + + // Create verification request + let verify_req = VerifySignatureRequest { + payload: payload.to_string(), + signature, + kid: signer.kid.clone(), + }; + + let body = serde_json::to_string(&verify_req).unwrap(); + let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); + req.set_body(body); + + // Handle the request + let mut resp = handle_verify_signature(&settings, req).unwrap(); + assert_eq!(resp.get_status(), StatusCode::OK); + + // Parse response + let resp_body = resp.take_body_str(); + let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).unwrap(); + + assert!(verify_resp.verified, "Signature should be verified"); + assert_eq!(verify_resp.kid, signer.kid); + assert!(verify_resp.error.is_none()); + } + + #[test] + fn test_handle_verify_signature_invalid() { + let settings = crate::test_support::tests::create_test_settings(); + let signer = crate::request_signing::RequestSigner::from_config().unwrap(); + + // Create a signature for a different payload + let wrong_signature = signer.sign(b"different payload").unwrap(); + + // Create request with signature that does not match the payload + let verify_req = VerifySignatureRequest { + payload: "test message".to_string(), + signature: wrong_signature, + kid: signer.kid.clone(), + }; + + let body = serde_json::to_string(&verify_req).unwrap(); + let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); + req.set_body(body); + + // Handle the request + let mut resp = handle_verify_signature(&settings, req).unwrap(); + assert_eq!(resp.get_status(), StatusCode::OK); + + // Parse response + let resp_body = resp.take_body_str(); + let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).unwrap(); + + assert!(!verify_resp.verified, "Invalid signature should not verify"); + assert_eq!(verify_resp.kid, signer.kid); + assert!(verify_resp.error.is_some()); + } + + #[test] + fn test_handle_verify_signature_malformed_request() { + let settings = crate::test_support::tests::create_test_settings(); + + let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); + req.set_body("not valid json"); + + // Should return an error response + let result = handle_verify_signature(&settings, req); + assert!(result.is_err(), "Malformed JSON should error"); + } + + #[test] + fn test_handle_rotate_key_with_empty_body() { + let settings = crate::test_support::tests::create_test_settings(); + let req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); + + let result = handle_rotate_key(&settings, req); + match result { + Ok(mut resp) => { + let body = resp.take_body_str(); + let response: RotateKeyResponse = serde_json::from_str(&body).unwrap(); + println!( + "Rotation response: success={}, message={}", + response.success, response.message + ); + } + Err(e) => println!("Expected error in test environment: {}", e), + } + } + + #[test] + fn test_handle_rotate_key_with_custom_kid() { + let settings = crate::test_support::tests::create_test_settings(); + + let req_body = RotateKeyRequest { + kid: Some("test-custom-key".to_string()), + }; + + let body_json = serde_json::to_string(&req_body).unwrap(); + let mut req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); + req.set_body(body_json); + + let result = handle_rotate_key(&settings, req); + match result { + Ok(mut resp) => { + let body = resp.take_body_str(); + let response: RotateKeyResponse = serde_json::from_str(&body).unwrap(); + println!( + "Custom KID rotation: success={}, new_kid={}", + response.success, response.new_kid + ); + } + Err(e) => println!("Expected error in test environment: {}", e), + } + } + + #[test] + fn test_handle_rotate_key_invalid_json() { + let settings = crate::test_support::tests::create_test_settings(); + let mut req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); + req.set_body("invalid json"); + + let result = handle_rotate_key(&settings, req); + assert!(result.is_err(), "Invalid JSON should return error"); + } + + #[test] + fn test_handle_deactivate_key_request() { + let settings = crate::test_support::tests::create_test_settings(); + + let req_body = DeactivateKeyRequest { + kid: "test-old-key".to_string(), + delete: false, + }; + + let body_json = serde_json::to_string(&req_body).unwrap(); + let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); + req.set_body(body_json); + + let result = handle_deactivate_key(&settings, req); + match result { + Ok(mut resp) => { + let body = resp.take_body_str(); + let response: DeactivateKeyResponse = serde_json::from_str(&body).unwrap(); + println!( + "Deactivate response: success={}, message={}", + response.success, response.message + ); + } + Err(e) => println!("Expected error in test environment: {}", e), + } + } + + #[test] + fn test_handle_deactivate_key_with_delete() { + let settings = crate::test_support::tests::create_test_settings(); + + let req_body = DeactivateKeyRequest { + kid: "test-old-key".to_string(), + delete: true, + }; + + let body_json = serde_json::to_string(&req_body).unwrap(); + let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); + req.set_body(body_json); + + let result = handle_deactivate_key(&settings, req); + match result { + Ok(mut resp) => { + let body = resp.take_body_str(); + let response: DeactivateKeyResponse = serde_json::from_str(&body).unwrap(); + println!( + "Delete response: success={}, deleted={}", + response.success, response.deleted + ); + } + Err(e) => println!("Expected error in test environment: {}", e), + } + } + + #[test] + fn test_handle_deactivate_key_invalid_json() { + let settings = crate::test_support::tests::create_test_settings(); + let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); + req.set_body("invalid json"); + + let result = handle_deactivate_key(&settings, req); + assert!(result.is_err(), "Invalid JSON should return error"); + } + + #[test] + fn test_rotate_key_request_deserialization() { + let json = r#"{"kid":"custom-key"}"#; + let req: RotateKeyRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.kid, Some("custom-key".to_string())); + } + + #[test] + fn test_deactivate_key_request_deserialization() { + let json = r#"{"kid":"old-key","delete":true}"#; + let req: DeactivateKeyRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.kid, "old-key"); + assert!(req.delete); + } +} diff --git a/crates/common/src/request_signing/jwks.rs b/crates/common/src/request_signing/jwks.rs new file mode 100644 index 0000000..abbf20c --- /dev/null +++ b/crates/common/src/request_signing/jwks.rs @@ -0,0 +1,113 @@ +//! JSON Web Key Set (JWKS) management. +//! +//! This module provides functionality for generating, storing, and retrieving +//! Ed25519 keypairs in JWK format for request signing. + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use jose_jwk::{ + jose_jwa::{Algorithm, Signing}, + Jwk, Key, Okp, OkpCurves, Parameters, +}; +use rand::rngs::OsRng; + +use crate::error::TrustedServerError; +use crate::fastly_storage::FastlyConfigStore; + +pub struct Keypair { + pub signing_key: SigningKey, + pub verifying_key: VerifyingKey, +} + +impl Keypair { + pub fn generate() -> Self { + let mut csprng = OsRng; + + let signing_key = SigningKey::generate(&mut csprng); + let verifying_key = signing_key.verifying_key(); + + Self { + signing_key, + verifying_key, + } + } + + pub fn get_jwk(&self, kid: String) -> Jwk { + let public_key_bytes = self.verifying_key.as_bytes(); + + let okp = Okp { + crv: OkpCurves::Ed25519, + x: public_key_bytes.to_vec().into(), + d: None, // No private key in JWK (public only) + }; + + Jwk { + key: Key::Okp(okp), + prm: Parameters { + kid: Some(kid), + alg: Some(Algorithm::Signing(Signing::EdDsa)), + ..Default::default() + }, + } + } +} + +pub fn get_active_jwks() -> Result { + let store = FastlyConfigStore::new("jwks_store"); + let active_kids_str = store.get("active-kids")?; + + let active_kids: Vec<&str> = active_kids_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + let mut jwks = Vec::new(); + for kid in active_kids { + let jwk = store.get(kid)?; + jwks.push(jwk); + } + + let keys_json = jwks.join(","); + Ok(format!(r#"{{"keys":[{}]}}"#, keys_json)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, Verifier}; + use jose_jwk::Key; + + #[test] + fn test_key_pair_generation() { + let keypair = Keypair::generate(); + + let message = b"test message"; + let signature = keypair.signing_key.sign(message); + + assert!(keypair.verifying_key.verify(message, &signature).is_ok()); + } + + #[test] + fn test_create_jwk_from_verifying_key() { + let jwk = Keypair::generate().get_jwk("test-kid".to_string()); + + // Verify JWK structure + assert_eq!(jwk.prm.kid, Some("test-kid".to_string())); + assert_eq!( + jwk.prm.alg, + Some(jose_jwk::jose_jwa::Algorithm::Signing( + jose_jwk::jose_jwa::Signing::EdDsa + )) + ); + + // Verify it's an OKP key with Ed25519 curve + match jwk.key { + Key::Okp(okp) => { + assert_eq!(okp.crv, OkpCurves::Ed25519); + assert_eq!(okp.x.len(), 32); // Ed25519 public keys are 32 bytes + assert!(okp.d.is_none()); // No private key component + } + _ => panic!("Expected OKP key type"), + } + } +} diff --git a/crates/common/src/request_signing/mod.rs b/crates/common/src/request_signing/mod.rs new file mode 100644 index 0000000..efa9ba7 --- /dev/null +++ b/crates/common/src/request_signing/mod.rs @@ -0,0 +1,14 @@ +//! Request signing utilities for secure communication. +//! +//! This module provides cryptographic signing capabilities using Ed25519 keys, +//! including JWKS management, key rotation, and signature verification. + +pub mod endpoints; +pub mod jwks; +pub mod rotation; +pub mod signing; + +pub use endpoints::*; +pub use jwks::*; +pub use rotation::*; +pub use signing::*; diff --git a/crates/common/src/request_signing/rotation.rs b/crates/common/src/request_signing/rotation.rs new file mode 100644 index 0000000..fd77dca --- /dev/null +++ b/crates/common/src/request_signing/rotation.rs @@ -0,0 +1,237 @@ +//! Key rotation management for request signing. +//! +//! This module provides functionality for rotating signing keys, managing key lifecycle, +//! and storing keys in Fastly Config and Secret stores. + +use base64::{engine::general_purpose, Engine}; +use ed25519_dalek::SigningKey; +use jose_jwk::Jwk; + +use crate::error::TrustedServerError; +use crate::fastly_storage::{FastlyApiClient, FastlyConfigStore}; + +use super::Keypair; + +#[derive(Debug, Clone)] +pub struct KeyRotationResult { + pub new_kid: String, + pub previous_kid: Option, + pub active_kids: Vec, + pub jwk: Jwk, +} + +pub struct KeyRotationManager { + config_store: FastlyConfigStore, + api_client: FastlyApiClient, + config_store_id: String, + secret_store_id: String, +} + +impl KeyRotationManager { + pub fn new( + config_store_id: impl Into, + secret_store_id: impl Into, + ) -> Result { + let config_store_id = config_store_id.into(); + let secret_store_id = secret_store_id.into(); + + let config_store = FastlyConfigStore::new("jwks_store"); + let api_client = FastlyApiClient::new()?; + + Ok(Self { + config_store, + api_client, + config_store_id, + secret_store_id, + }) + } + + pub fn rotate_key(&self, kid: Option) -> Result { + let new_kid = kid.unwrap_or_else(generate_date_based_kid); + + let keypair = Keypair::generate(); + let jwk = keypair.get_jwk(new_kid.clone()); + let previous_kid = self.config_store.get("current-kid").ok(); + + self.store_private_key(&new_kid, &keypair.signing_key)?; + self.store_public_jwk(&new_kid, &jwk)?; + + let active_kids = match &previous_kid { + Some(prev) if prev != &new_kid => vec![prev.clone(), new_kid.clone()], + _ => vec![new_kid.clone()], + }; + + self.update_current_kid(&new_kid)?; + self.update_active_kids(&active_kids)?; + + Ok(KeyRotationResult { + new_kid, + previous_kid, + active_kids, + jwk, + }) + } + + fn store_private_key( + &self, + kid: &str, + signing_key: &SigningKey, + ) -> Result<(), TrustedServerError> { + let key_bytes = signing_key.as_bytes(); + let key_b64 = general_purpose::STANDARD.encode(key_bytes); + + self.api_client + .create_secret(&self.secret_store_id, kid, &key_b64) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to store private key '{}': {}", kid, e), + }) + } + + fn store_public_jwk(&self, kid: &str, jwk: &Jwk) -> Result<(), TrustedServerError> { + let jwk_json = + serde_json::to_string(jwk).map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to serialize JWK: {}", e), + })?; + + self.api_client + .update_config_item(&self.config_store_id, kid, &jwk_json) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to store public JWK '{}': {}", kid, e), + }) + } + + fn update_current_kid(&self, kid: &str) -> Result<(), TrustedServerError> { + self.api_client + .update_config_item(&self.config_store_id, "current-kid", kid) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to update current-kid: {}", e), + }) + } + + fn update_active_kids(&self, active_kids: &[String]) -> Result<(), TrustedServerError> { + let active_kids_str = active_kids.join(","); + + self.api_client + .update_config_item(&self.config_store_id, "active-kids", &active_kids_str) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to update active-kids: {}", e), + }) + } + + pub fn list_active_keys(&self) -> Result, TrustedServerError> { + let active_kids_str = self.config_store.get("active-kids")?; + + let active_kids: Vec = active_kids_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + Ok(active_kids) + } + + pub fn deactivate_key(&self, kid: &str) -> Result<(), TrustedServerError> { + let mut active_kids = self.list_active_keys()?; + + active_kids.retain(|k| k != kid); + + if active_kids.is_empty() { + return Err(TrustedServerError::Configuration { + message: "Cannot deactivate the last active key".into(), + }); + } + + self.update_active_kids(&active_kids) + } + + pub fn delete_key(&self, kid: &str) -> Result<(), TrustedServerError> { + self.deactivate_key(kid)?; + + self.api_client + .delete_config_item(&self.config_store_id, kid) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to delete JWK from ConfigStore: {}", e), + })?; + + self.api_client + .delete_secret(&self.secret_store_id, kid) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to delete secret from SecretStore: {}", e), + })?; + + Ok(()) + } +} + +pub fn generate_date_based_kid() -> String { + use chrono::Utc; + format!("ts-{}", Utc::now().format("%Y-%m-%d")) +} + +#[cfg(test)] +mod tests { + use crate::request_signing::Keypair; + + use super::*; + + #[test] + fn test_generate_date_based_kid() { + let kid = generate_date_based_kid(); + println!("Generated KID: {}", kid); + + // Verify format: ts-YYYY-MM-DD + assert!(kid.starts_with("ts-")); + assert!(kid.len() >= 13); // "ts-" + "YYYY-MM-DD" = 13 chars minimum + + // Verify it contains only valid characters + let parts: Vec<&str> = kid.split('-').collect(); + assert_eq!(parts.len(), 4); // ["ts", "YYYY", "MM", "DD"] + assert_eq!(parts[0], "ts"); + } + + #[test] + fn test_key_rotation_manager_creation() { + let result = KeyRotationManager::new("jwks_store", "signing_keys"); + match result { + Ok(manager) => { + assert_eq!(manager.config_store_id, "jwks_store"); + assert_eq!(manager.secret_store_id, "signing_keys"); + println!("✓ KeyRotationManager created successfully"); + } + Err(e) => { + println!("Expected error in test environment: {}", e); + } + } + } + + #[test] + fn test_list_active_keys() { + let result = KeyRotationManager::new("jwks_store", "signing_keys"); + if let Ok(manager) = result { + match manager.list_active_keys() { + Ok(keys) => { + println!("Active keys: {:?}", keys); + assert!(!keys.is_empty(), "Should have at least one active key"); + } + Err(e) => println!("Expected error in test environment: {}", e), + } + } + } + + #[test] + fn test_key_rotation_result_structure() { + let jwk = Keypair::generate().get_jwk("test-key".to_string()); + + let result = KeyRotationResult { + new_kid: "ts-2024-01-01".to_string(), + previous_kid: Some("ts-2023-12-31".to_string()), + active_kids: vec!["ts-2023-12-31".to_string(), "ts-2024-01-01".to_string()], + jwk: jwk.clone(), + }; + + assert_eq!(result.new_kid, "ts-2024-01-01"); + assert_eq!(result.previous_kid, Some("ts-2023-12-31".to_string())); + assert_eq!(result.active_kids.len(), 2); + assert_eq!(result.jwk.prm.kid, Some("test-key".to_string())); + } +} diff --git a/crates/common/src/request_signing/signing.rs b/crates/common/src/request_signing/signing.rs new file mode 100644 index 0000000..6420a12 --- /dev/null +++ b/crates/common/src/request_signing/signing.rs @@ -0,0 +1,201 @@ +//! Request signing and verification utilities. +//! +//! This module provides Ed25519-based signing and verification of HTTP requests +//! using keys stored in Fastly Config and Secret stores. + +use base64::{engine::general_purpose, Engine}; +use ed25519_dalek::{Signature, Signer as Ed25519Signer, SigningKey, Verifier, VerifyingKey}; + +use crate::error::TrustedServerError; +use crate::fastly_storage::{FastlyConfigStore, FastlySecretStore}; + +pub fn get_current_key_id() -> Result { + let store = FastlyConfigStore::new("jwks_store"); + store.get("current-kid") +} + +fn parse_ed25519_signing_key(key_bytes: Vec) -> Result { + let bytes = if key_bytes.len() > 32 { + general_purpose::STANDARD.decode(&key_bytes).map_err(|_| { + TrustedServerError::Configuration { + message: "Failed to decode base64 key".into(), + } + })? + } else { + key_bytes + }; + + let key_array: [u8; 32] = bytes + .try_into() + .map_err(|_| TrustedServerError::Configuration { + message: "Invalid key length (expected 32 bytes for Ed25519)".into(), + })?; + + Ok(SigningKey::from_bytes(&key_array)) +} + +pub struct RequestSigner { + key: SigningKey, + pub kid: String, +} + +impl RequestSigner { + pub fn from_config() -> Result { + let config_store = FastlyConfigStore::new("jwks_store"); + let key_id = config_store.get("current-kid")?; + + let secret_store = FastlySecretStore::new("signing_keys"); + let key_bytes = secret_store.get(&key_id)?; + let signing_key = parse_ed25519_signing_key(key_bytes)?; + + Ok(Self { + key: signing_key, + kid: key_id, + }) + } + + pub fn sign(&self, payload: &[u8]) -> Result { + let signature_bytes = self.key.sign(payload).to_bytes(); + + Ok(general_purpose::URL_SAFE_NO_PAD.encode(signature_bytes)) + } +} + +pub fn verify_signature( + payload: &[u8], + signature_b64: &str, + kid: &str, +) -> Result { + let store = FastlyConfigStore::new("jwks_store"); + let jwk_json = store.get(kid)?; + + let jwk: serde_json::Value = + serde_json::from_str(&jwk_json).map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to parse JWK: {}", e), + })?; + + let x_b64 = + jwk.get("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| TrustedServerError::Configuration { + message: "JWK missing 'x' parameter".into(), + })?; + + let public_key_bytes = general_purpose::URL_SAFE_NO_PAD + .decode(x_b64) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to decode public key: {}", e), + })?; + + let verifying_key_bytes: [u8; 32] = + public_key_bytes + .try_into() + .map_err(|_| TrustedServerError::Configuration { + message: "Public key must be 32 bytes".into(), + })?; + + let verifying_key = VerifyingKey::from_bytes(&verifying_key_bytes).map_err(|e| { + TrustedServerError::Configuration { + message: format!("Failed to create verifying key: {}", e), + } + })?; + + let signature_bytes = general_purpose::URL_SAFE_NO_PAD + .decode(signature_b64) + .or_else(|_| general_purpose::STANDARD.decode(signature_b64)) + .map_err(|e| TrustedServerError::Configuration { + message: format!("Failed to decode signature: {}", e), + })?; + + let signature_array: [u8; 64] = + signature_bytes + .try_into() + .map_err(|_| TrustedServerError::Configuration { + message: "Signature must be 64 bytes".into(), + })?; + + let signature = Signature::from_bytes(&signature_array); + + Ok(verifying_key.verify(payload, &signature).is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_signer_sign() { + let signer = RequestSigner::from_config().unwrap(); + let signature = signer + .sign(b"these pretzels are making me thirsty") + .unwrap(); + assert!(!signature.is_empty()); + assert!(signature.len() > 32); // Ed25519 signatures are 64 bytes, base64 encoded should be longer + } + + #[test] + fn test_request_signer_from_config() { + let signer = RequestSigner::from_config().unwrap(); + // Verify that we can successfully load the signing key from Fastly config + assert!(!signer.kid.is_empty()); + } + + #[test] + fn test_sign_and_verify() { + let payload = b"test payload for verification"; + let signer = RequestSigner::from_config().unwrap(); + let signature = signer.sign(payload).unwrap(); + + // Verify the signature + let result = verify_signature(payload, &signature, &signer.kid).unwrap(); + assert!(result, "Signature should be valid"); + } + + #[test] + fn test_verify_invalid_signature() { + let payload = b"test payload"; + let signer = RequestSigner::from_config().unwrap(); + + // Create a valid Ed25519 signature (64 bytes) but for a different payload + let wrong_signature = signer.sign(b"different payload").unwrap(); + + // Should return false for signature of different payload + let result = verify_signature(payload, &wrong_signature, &signer.kid).unwrap(); + assert!(!result, "Invalid signature should not verify"); + } + + #[test] + fn test_verify_wrong_payload() { + let original_payload = b"original payload"; + let signer = RequestSigner::from_config().unwrap(); + let signature = signer.sign(original_payload).unwrap(); + + // Try to verify with different payload + let wrong_payload = b"wrong payload"; + let result = verify_signature(wrong_payload, &signature, &signer.kid).unwrap(); + assert!(!result, "Signature should not verify with wrong payload"); + } + + #[test] + fn test_verify_missing_key() { + let payload = b"test payload"; + let signer = RequestSigner::from_config().unwrap(); + let signature = signer.sign(payload).unwrap(); + let nonexistent_kid = "nonexistent-key-id"; + + // Should return an error for missing key + let result = verify_signature(payload, &signature, nonexistent_kid); + assert!(result.is_err(), "Should error for missing key"); + } + + #[test] + fn test_verify_malformed_signature() { + let payload = b"test payload"; + let signer = RequestSigner::from_config().unwrap(); + let malformed_signature = "not-valid-base64!!!"; + + // Should return an error for malformed base64 + let result = verify_signature(payload, malformed_signature, &signer.kid); + assert!(result.is_err(), "Should error for malformed signature"); + } +} diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index b46ba1c..eb80401 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -124,6 +124,18 @@ impl Handler { } } +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct RequestSigning { + #[serde(default = "default_request_signing_enabled")] + pub enabled: bool, + pub config_store_id: String, + pub secret_store_id: String, +} + +fn default_request_signing_enabled() -> bool { + false +} + #[derive(Debug, Default, Deserialize, Serialize, Validate)] pub struct Settings { #[validate(nested)] @@ -137,6 +149,7 @@ pub struct Settings { pub handlers: Vec, #[serde(default)] pub response_headers: HashMap, + pub request_signing: Option, } #[allow(unused)] diff --git a/crates/common/src/test_support.rs b/crates/common/src/test_support.rs index 012d759..da820ac 100644 --- a/crates/common/src/test_support.rs +++ b/crates/common/src/test_support.rs @@ -24,6 +24,9 @@ pub mod tests { opid_store = "test-opid-store" secret_key = "test-secret-key" template = "{{client_ip}}:{{user_agent}}:{{first_party_id}}:{{auth_user_id}}:{{publisher_domain}}:{{accept_language}}" + [request_signing] + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" "#.to_string() } diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 658e37e..ff13aea 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -9,6 +9,9 @@ use trusted_server_common::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_common::publisher::{handle_publisher_request, handle_tsjs_dynamic}; +use trusted_server_common::request_signing::{ + handle_deactivate_key, handle_jwks_endpoint, handle_rotate_key, handle_verify_signature, +}; use trusted_server_common::settings::Settings; use trusted_server_common::settings_data::get_settings; @@ -52,6 +55,16 @@ async fn route_request(settings: Settings, req: Request) -> Result handle_jwks_endpoint(&settings, req), + + // Signature verification endpoint + (&Method::POST, "/verify-signature") => handle_verify_signature(&settings, req), + + // Key rotation admin endpoints + (&Method::POST, "/admin/keys/rotate") => handle_rotate_key(&settings, req), + (&Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(&settings, req), + // tsjs endpoints (&Method::GET, "/first-party/ad") => handle_server_ad_get(&settings, req).await, (&Method::POST, "/third-party/ad") => handle_server_ad(&settings, req).await, diff --git a/fastly.toml b/fastly.toml index 3a13224..fcaf92b 100644 --- a/fastly.toml +++ b/fastly.toml @@ -32,3 +32,21 @@ build = """ [[local_server.kv_stores.opid_store]] key = "placeholder" data = "placeholder" + [local_server.secret_stores] + [[local_server.secret_stores.signing_keys]] + key = "ts-2025-10-A" + data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" + + [[local_server.secret_stores.api-keys]] + key = "api_key" + env = "FASTLY_KEY" + + [local_server.config_stores] + [local_server.config_stores.jwks_store] + format = "inline-toml" + [local_server.config_stores.jwks_store.contents] + ts-2025-10-A = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"ts-2025-10-A\",\"use\":\"sig\",\"x\":\"UVTi04QLrIuB7jXpVfHjUTVN5aIdcbPNr50umTtN8pw\"}" + ts-2025-10-B = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"ts-2025-10-B\",\"use\":\"sig\",\"x\":\"HVTi04QLrIuB7jXpVfHjUTVN5aIdcbPNr50umTtN8pw\"}" + current-kid = "ts-2025-10-A" + active-kids = "ts-2025-10-A,ts-2025-10-B" + diff --git a/trusted-server.toml b/trusted-server.toml index 50ae25a..2e6db71 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -32,3 +32,10 @@ template = "{{ client_ip }}:{{ user_agent }}:{{ first_party_id }}:{{ auth_user_i # Custom headers to be included in every response [response_headers] X-Custom-Header = "custom header value" + +# Request Signing Configuration +# Enable signing of OpenRTB requests and other API calls +[request_signing] +enabled = false # Set to true to enable request signing +config_store_id = "" # set config/secret store ids for key rotation +secret_store_id = ""