//! Shared helpers for encrypted paste payloads. use anyhow::{Context, Result}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; /// Decode the shared paste key from raw bytes, hex, or base64. /// /// Inputs: the raw operator-supplied secret, optionally prefixed with `hex:` or /// `raw:`. /// Outputs: a 32-byte key suitable for ChaCha20-Poly1305. /// # Errors /// /// Returns an error when the input is not valid base64/hex or does not decode /// to exactly 32 bytes, and is not already an exact 32-byte secret. /// Why: both the client and server enforce the same secret format, so this /// logic lives in one place instead of drifting across crates. pub fn decode_shared_key(raw: &str) -> Result<[u8; 32]> { let trimmed = raw.trim(); let bytes = if let Some(payload) = trimmed.strip_prefix("raw:") { payload.as_bytes().to_vec() } else { let payload = trimmed.strip_prefix("hex:").unwrap_or(trimmed); if payload.len() == 64 && payload.chars().all(|c| c.is_ascii_hexdigit()) { hex_to_bytes(payload)? } else { match STANDARD.decode(payload.as_bytes()) { Ok(decoded) if decoded.len() == 32 => decoded, Ok(_) | Err(_) if payload.as_bytes().len() == 32 => payload.as_bytes().to_vec(), Ok(_) => anyhow::bail!("LESAVKA_PASTE_KEY must decode to 32 bytes"), Err(err) => { return Err(err).context( "LESAVKA_PASTE_KEY must be a 32-byte raw secret, 32-byte base64, or 64-char hex", ); } } } }; if bytes.len() != 32 { anyhow::bail!("LESAVKA_PASTE_KEY must decode to 32 bytes"); } let mut key = [0u8; 32]; key.copy_from_slice(&bytes); Ok(key) } /// Trim paste text to the configured character budget. /// /// Inputs: the full clipboard text plus the maximum character count allowed by /// the current deployment. /// Outputs: the original text when already within the limit, or a truncated /// clone that contains exactly the leading `max_chars` Unicode scalar values. /// Why: client and server need the same truncation semantics so operators see /// predictable behavior regardless of where the limit is enforced first. #[must_use] pub fn truncate_text(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { text.to_string() } else { text.chars().take(max_chars).collect() } } fn hex_to_bytes(raw: &str) -> Result> { let chars: Vec = raw.chars().collect(); let mut bytes = Vec::with_capacity(chars.len() / 2); for index in (0..chars.len()).step_by(2) { let hi = chars[index].to_digit(16).context("hex decode failed")?; let lo = chars[index + 1].to_digit(16).context("hex decode failed")?; bytes.push(u8::try_from((hi << 4) | lo).context("hex decode failed")?); } Ok(bytes) } #[cfg(test)] mod tests { use super::{decode_shared_key, truncate_text}; const HEX_KEY: &str = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; const B64_KEY: &str = "ABEiM0RVZneImaq7zN3u/wARIjNEVWZ3iJmqu8zd7v8="; const RAW_KEY: &str = "0123456789abcdef0123456789abcdef"; #[test] fn decode_shared_key_accepts_hex_and_base64() { let hex = decode_shared_key(HEX_KEY).expect("hex key should decode"); let base64 = decode_shared_key(B64_KEY).expect("base64 key should decode"); assert_eq!(hex, base64); assert_eq!(hex[0], 0x00); assert_eq!(hex[31], 0xff); } #[test] fn decode_shared_key_accepts_raw_32_byte_secret() { let raw = decode_shared_key(RAW_KEY).expect("raw key should decode"); let explicit = decode_shared_key("raw:0123456789abcdef0123456789abcdef") .expect("raw-prefixed key should decode"); assert_eq!(raw, explicit); assert_eq!(raw, *RAW_KEY.as_bytes()); } #[test] fn decode_shared_key_rejects_short_input() { let error = decode_shared_key("Zm9v").expect_err("short key must fail"); assert!(error.to_string().contains("32 bytes")); } #[test] fn decode_shared_key_rejects_invalid_hex_payload() { let error = decode_shared_key( "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeefg", ) .expect_err("invalid hex key must fail"); assert!(error.to_string().contains("32 bytes")); } #[test] fn decode_shared_key_rejects_wrong_length_raw_prefix() { let error = decode_shared_key("raw:too-short").expect_err("short raw key must fail"); assert!(error.to_string().contains("32 bytes")); } #[test] fn truncate_text_preserves_unicode_boundaries() { assert_eq!(truncate_text("abc", 10), "abc"); assert_eq!(truncate_text("naΓ―ve", 4), "naΓ―v"); assert_eq!(truncate_text("πŸ™‚πŸ™‚πŸ™‚", 2), "πŸ™‚πŸ™‚"); } }