2026-04-10 15:56:18 -03:00
|
|
|
//! Shared helpers for encrypted paste payloads.
|
|
|
|
|
|
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
use base64::engine::general_purpose::STANDARD;
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
/// Decode the shared paste key from raw bytes, hex, or base64.
|
2026-04-10 15:56:18 -03:00
|
|
|
///
|
2026-04-16 12:58:05 -03:00
|
|
|
/// Inputs: the raw operator-supplied secret, optionally prefixed with `hex:` or
|
|
|
|
|
/// `raw:`.
|
2026-04-10 15:56:18 -03:00
|
|
|
/// 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
|
2026-04-16 12:58:05 -03:00
|
|
|
/// to exactly 32 bytes, and is not already an exact 32-byte secret.
|
2026-04-10 15:56:18 -03:00
|
|
|
/// 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();
|
2026-04-16 12:58:05 -03:00
|
|
|
let bytes = if let Some(payload) = trimmed.strip_prefix("raw:") {
|
|
|
|
|
payload.as_bytes().to_vec()
|
2026-04-10 15:56:18 -03:00
|
|
|
} else {
|
2026-04-16 12:58:05 -03:00
|
|
|
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,
|
2026-04-23 07:00:06 -03:00
|
|
|
Ok(_) | Err(_) if payload.len() == 32 => payload.as_bytes().to_vec(),
|
2026-04-16 12:58:05 -03:00
|
|
|
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",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 15:56:18 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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=";
|
2026-04-16 12:58:05 -03:00
|
|
|
const RAW_KEY: &str = "0123456789abcdef0123456789abcdef";
|
2026-04-10 15:56:18 -03:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
#[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), "🙂🙂");
|
|
|
|
|
}
|
|
|
|
|
}
|