lesavka/common/src/paste.rs

133 lines
4.9 KiB
Rust

//! 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<Vec<u8>> {
let chars: Vec<char> = 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), "🙂🙂");
}
}