diff --git a/.changeset/vault-wasm-crypto.md b/.changeset/vault-wasm-crypto.md new file mode 100644 index 0000000..234866b --- /dev/null +++ b/.changeset/vault-wasm-crypto.md @@ -0,0 +1,6 @@ +--- +"sdk": minor +"core": patch +--- + +Add vault WASM crypto functions (AES-256-GCM, HKDF-SHA256, X25519) and sanitize public endpoint metadata headers diff --git a/Cargo.lock b/Cargo.lock index e3bbf5f..69ff408 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -1863,6 +1898,16 @@ dependencies = [ "unsigned-varint 0.8.0", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clear_on_drop" version = "0.2.5" @@ -2118,6 +2163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2151,6 +2197,15 @@ dependencies = [ "sct 0.6.1", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -3207,6 +3262,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -3866,6 +3931,15 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -5492,6 +5566,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -8734,19 +8820,23 @@ dependencies = [ name = "tinycloud-sdk-wasm" version = "1.0.0" dependencies = [ + "aes-gcm", "chrono", "console_error_panic_hook", "getrandom 0.2.17", "hex", + "hkdf", "http 1.4.0", "js-sys", "k256", "lazy_static", "serde", "serde-wasm-bindgen", + "serde_bytes", "serde_ipld_dagcbor 0.6.4", "serde_json", "serde_with 3.16.1", + "sha2 0.10.9", "siwe", "siwe-recap", "thiserror 2.0.18", @@ -8756,6 +8846,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "x25519-dalek", ] [[package]] @@ -9266,6 +9357,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsigned-varint" version = "0.7.2" @@ -9917,6 +10018,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.13.2" diff --git a/src/routes/public.rs b/src/routes/public.rs index 2fc9d94..1d0ab53 100644 --- a/src/routes/public.rs +++ b/src/routes/public.rs @@ -11,7 +11,8 @@ use tinycloud_core::storage::{Content, ImmutableReadStore}; use tinycloud_lib::resource::{Path, SpaceId}; use tokio_util::compat::FuturesAsyncReadCompatExt; -use crate::{auth_guards::ObjectHeaders, config::PublicSpacesConfig, BlockStores, TinyCloud}; +use crate::{config::PublicSpacesConfig, BlockStores, TinyCloud}; +use tinycloud_core::types::Metadata; /// A key path that allows dot-prefixed segments like `.well-known/profile`. /// Unlike `std::path::PathBuf`, this does not reject hidden files/dirs. @@ -119,6 +120,15 @@ const CORS_METHODS: &str = "GET, HEAD, OPTIONS"; const CORS_ALLOW_HEADERS: &str = "If-None-Match"; const CORS_EXPOSE_HEADERS: &str = "ETag, Content-Type, Content-Length"; +/// Headers safe to expose on unauthenticated public endpoints. +/// Everything else (authorization, host, user-agent, etc.) is stripped. +const PUBLIC_SAFE_HEADERS: &[&str] = &["content-type", "content-encoding", "content-language"]; + +fn sanitized_metadata(md: &Metadata) -> impl Iterator { + md.0.iter() + .filter(|(k, _)| PUBLIC_SAFE_HEADERS.contains(&k.to_lowercase().as_str())) +} + fn add_public_headers(response: &mut Response<'_>, etag: Option<&str>) { response.set_header(Header::new("Cache-Control", CACHE_CONTROL)); response.set_header(Header::new("Access-Control-Allow-Origin", CORS_ORIGIN)); @@ -136,16 +146,17 @@ fn add_public_headers(response: &mut Response<'_>, etag: Option<&str>) { } } -pub struct PublicKVResponse(Content, ObjectHeaders, String); +pub struct PublicKVResponse(Content, Metadata, String); impl<'r, R> Responder<'r, 'static> for PublicKVResponse where R: 'static + AsyncRead + Send, { - fn respond_to(self, r: &'r Request<'_>) -> rocket::response::Result<'static> { - let mut response = Response::build_from(self.1.respond_to(r)?) - .streamed_body(self.0.compat()) - .finalize(); + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build().streamed_body(self.0.compat()).finalize(); + for (k, v) in sanitized_metadata(&self.1) { + response.set_header(Header::new(k.clone(), v.clone())); + } add_public_headers(&mut response, Some(&self.2)); Ok(response) } @@ -161,11 +172,14 @@ impl<'r> Responder<'r, 'static> for NotModifiedResponse { } } -pub struct PublicMetadataResponse(ObjectHeaders, String); +pub struct PublicMetadataResponse(Metadata, String); impl<'r> Responder<'r, 'static> for PublicMetadataResponse { - fn respond_to(self, r: &'r Request<'_>) -> rocket::response::Result<'static> { - let mut response = self.0.respond_to(r)?; + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build().finalize(); + for (k, v) in sanitized_metadata(&self.0) { + response.set_header(Header::new(k.clone(), v.clone())); + } add_public_headers(&mut response, Some(&self.1)); Ok(response) } @@ -227,7 +241,7 @@ pub async fn public_kv_get( } } - Ok(Ok(PublicKVResponse(content, ObjectHeaders(md), etag))) + Ok(Ok(PublicKVResponse(content, md, etag))) } None => Err((Status::NotFound, "Key not found".to_string())), } @@ -274,7 +288,7 @@ pub async fn public_kv_head( } } - Ok(Ok(PublicMetadataResponse(ObjectHeaders(md), etag))) + Ok(Ok(PublicMetadataResponse(md, etag))) } None => Err((Status::NotFound, "Key not found".to_string())), } diff --git a/tinycloud-sdk-wasm/Cargo.toml b/tinycloud-sdk-wasm/Cargo.toml index 5b8abf1..1b32ac1 100644 --- a/tinycloud-sdk-wasm/Cargo.toml +++ b/tinycloud-sdk-wasm/Cargo.toml @@ -37,3 +37,8 @@ web-sys = { version = "0.3", features = [ ] } uuid = { version = "1.3.4", features = ["v4", "js"] } getrandom = { version = "0.2", features = ["js"] } +aes-gcm = "0.10" +hkdf = "0.12" +sha2 = "0.10" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +serde_bytes = "0.11" diff --git a/tinycloud-sdk-wasm/src/lib.rs b/tinycloud-sdk-wasm/src/lib.rs index 856f4de..5bd88c1 100644 --- a/tinycloud-sdk-wasm/src/lib.rs +++ b/tinycloud-sdk-wasm/src/lib.rs @@ -1,6 +1,7 @@ mod definitions; pub mod host; pub mod session; +pub mod vault; use hex::FromHex; use tinycloud_lib::{ diff --git a/tinycloud-sdk-wasm/src/vault.rs b/tinycloud-sdk-wasm/src/vault.rs new file mode 100644 index 0000000..d613c15 --- /dev/null +++ b/tinycloud-sdk-wasm/src/vault.rs @@ -0,0 +1,139 @@ +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use hkdf::Hkdf; +use sha2::Sha256; +use wasm_bindgen::prelude::*; +use x25519_dalek::{PublicKey, StaticSecret}; + +fn map_vault_err(msg: &str) -> JsValue { + JsValue::from_str(msg) +} + +/// AES-256-GCM encrypt. +/// Returns [12-byte nonce || ciphertext || 16-byte tag]. +#[wasm_bindgen] +pub fn vault_encrypt(key: &[u8], plaintext: &[u8]) -> Result, JsValue> { + if key.len() != 32 { + return Err(map_vault_err("key must be 32 bytes")); + } + + let mut nonce_bytes = [0u8; 12]; + getrandom::getrandom(&mut nonce_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?; + + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| JsValue::from_str(&e.to_string()))?; + let nonce = Nonce::from(nonce_bytes); + + let ciphertext_with_tag = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let mut result = Vec::with_capacity(12 + ciphertext_with_tag.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext_with_tag); + + Ok(result) +} + +/// AES-256-GCM decrypt. +/// Expects input as [12-byte nonce || ciphertext || 16-byte tag]. +#[wasm_bindgen] +pub fn vault_decrypt(key: &[u8], blob: &[u8]) -> Result, JsValue> { + if key.len() != 32 { + return Err(map_vault_err("key must be 32 bytes")); + } + if blob.len() < 28 { + return Err(map_vault_err( + "blob too short: need at least 28 bytes (12 nonce + 16 tag)", + )); + } + + let (nonce_bytes, ciphertext_with_tag) = blob.split_at(12); + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| JsValue::from_str(&e.to_string()))?; + let nonce_array: [u8; 12] = nonce_bytes + .try_into() + .map_err(|_| map_vault_err("invalid nonce length"))?; + let nonce = Nonce::from(nonce_array); + + let plaintext = cipher + .decrypt(&nonce, ciphertext_with_tag) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(plaintext) +} + +/// HKDF-SHA256 key derivation. +/// Returns a 32-byte derived key. +#[wasm_bindgen] +pub fn vault_derive_key(salt: &[u8], signature: &[u8], info: &[u8]) -> Result, JsValue> { + let hk = Hkdf::::new(Some(salt), signature); + let mut output = [0u8; 32]; + hk.expand(info, &mut output) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(output.to_vec()) +} + +#[derive(serde::Serialize)] +struct X25519KeyPair { + #[serde(rename = "publicKey", with = "serde_bytes")] + public_key: Vec, + #[serde(rename = "privateKey", with = "serde_bytes")] + private_key: Vec, +} + +/// Generate an X25519 keypair from a 32-byte seed. +/// Returns a JS object with `publicKey` and `privateKey` as Uint8Array. +#[wasm_bindgen] +pub fn vault_x25519_from_seed(seed: &[u8]) -> Result { + if seed.len() != 32 { + return Err(map_vault_err("seed must be 32 bytes")); + } + + let seed_array: [u8; 32] = seed.try_into().unwrap(); + let secret = StaticSecret::from(seed_array); + let public = PublicKey::from(&secret); + + let keypair = X25519KeyPair { + public_key: public.as_bytes().to_vec(), + private_key: secret.to_bytes().to_vec(), + }; + + serde_wasm_bindgen::to_value(&keypair).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// X25519 Diffie-Hellman shared secret computation. +/// Both private_key and public_key must be 32 bytes. +#[wasm_bindgen] +pub fn vault_x25519_dh(private_key: &[u8], public_key: &[u8]) -> Result, JsValue> { + if private_key.len() != 32 { + return Err(map_vault_err("private_key must be 32 bytes")); + } + if public_key.len() != 32 { + return Err(map_vault_err("public_key must be 32 bytes")); + } + + let priv_array: [u8; 32] = private_key.try_into().unwrap(); + let pub_array: [u8; 32] = public_key.try_into().unwrap(); + + let secret = StaticSecret::from(priv_array); + let their_public = PublicKey::from(pub_array); + + let shared_secret = secret.diffie_hellman(&their_public); + Ok(shared_secret.as_bytes().to_vec()) +} + +/// Generate cryptographically secure random bytes. +#[wasm_bindgen] +pub fn vault_random_bytes(length: usize) -> Result, JsValue> { + let mut buf = vec![0u8; length]; + getrandom::getrandom(&mut buf).map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(buf) +} + +/// SHA-256 hash of the input data. +#[wasm_bindgen] +pub fn vault_sha256(data: &[u8]) -> Vec { + use sha2::Digest; + Sha256::new().chain_update(data).finalize().to_vec() +}