use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use crypto_box::{PublicKey, SecretKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SealedSecret {
pub name: String,
pub version: u32,
pub key_id: String,
pub ciphertext_b64: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SealedError {
#[error("base64 decode failed: {0}")]
Decode(#[from] base64::DecodeError),
#[error("ciphertext invalid length: {0}")]
InvalidLength(usize),
#[error("encryption failed")]
Encrypt,
#[error("decryption failed")]
Decrypt,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecipientPublicKey([u8; 32]);
impl RecipientPublicKey {
#[must_use]
pub fn from_bytes(b: [u8; 32]) -> Self {
Self(b)
}
pub fn from_base64(s: &str) -> Result<Self, SealedError> {
let bytes = B64.decode(s)?;
if bytes.len() != 32 {
return Err(SealedError::InvalidLength(bytes.len()));
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(Self(buf))
}
#[must_use]
pub fn to_base64(&self) -> String {
B64.encode(self.0)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct RecipientPrivateKey([u8; 32]);
impl RecipientPrivateKey {
#[must_use]
pub fn generate() -> (RecipientPrivateKey, RecipientPublicKey) {
let sk = SecretKey::generate(&mut crypto_box::aead::OsRng);
let pk = sk.public_key();
let sk_bytes: [u8; 32] = sk.to_bytes();
let pk_bytes: [u8; 32] = pk.as_bytes().to_owned();
(Self(sk_bytes), RecipientPublicKey(pk_bytes))
}
#[must_use]
pub fn from_bytes(b: [u8; 32]) -> Self {
Self(b)
}
pub fn from_base64(s: &str) -> Result<Self, SealedError> {
let bytes = B64.decode(s)?;
if bytes.len() != 32 {
return Err(SealedError::InvalidLength(bytes.len()));
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(Self(buf))
}
#[must_use]
pub fn to_base64(&self) -> String {
B64.encode(self.0)
}
#[must_use]
pub fn public_key(&self) -> RecipientPublicKey {
let sk = SecretKey::from_bytes(self.0);
let pk = sk.public_key();
RecipientPublicKey(pk.as_bytes().to_owned())
}
}
pub fn seal(plaintext: &[u8], recipient_pub: &RecipientPublicKey) -> Result<String, SealedError> {
let pk = PublicKey::from(*recipient_pub.as_bytes());
let ct = pk
.seal(&mut crypto_box::aead::OsRng, plaintext)
.map_err(|_| SealedError::Encrypt)?;
Ok(B64.encode(ct))
}
pub fn open(
ciphertext_b64: &str,
recipient_priv: &RecipientPrivateKey,
) -> Result<Vec<u8>, SealedError> {
let bytes = B64.decode(ciphertext_b64)?;
let sk = SecretKey::from_bytes(recipient_priv.0);
sk.unseal(&bytes).map_err(|_| SealedError::Decrypt)
}
#[must_use]
pub fn fingerprint(public_key: &RecipientPublicKey) -> String {
let mut hasher = Sha256::new();
hasher.update(public_key.as_bytes());
let digest = hasher.finalize();
format!("sha256:{}", hex::encode(&digest[..8]))
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::OsRng;
use rand::TryRngCore;
#[test]
fn roundtrip() {
let (sk, pk) = RecipientPrivateKey::generate();
let mut plaintext = [0u8; 32];
OsRng.try_fill_bytes(&mut plaintext).expect("OS RNG failed");
let ct = seal(&plaintext, &pk).expect("seal");
let pt = open(&ct, &sk).expect("open");
assert_eq!(pt, plaintext);
}
#[test]
fn tamper_byte_fails() {
let (sk, pk) = RecipientPrivateKey::generate();
let plaintext = b"super-secret-payload";
let ct_b64 = seal(plaintext, &pk).expect("seal");
let mut bytes = B64.decode(&ct_b64).expect("decode");
let last = bytes.last_mut().expect("non-empty ciphertext");
*last ^= 0xFF;
let tampered = B64.encode(&bytes);
let result = open(&tampered, &sk);
assert!(matches!(result, Err(SealedError::Decrypt)));
}
#[test]
fn wrong_key_fails() {
let (_sk_a, pk_a) = RecipientPrivateKey::generate();
let (sk_b, _pk_b) = RecipientPrivateKey::generate();
let plaintext = b"sealed to A only";
let ct = seal(plaintext, &pk_a).expect("seal");
let result = open(&ct, &sk_b);
assert!(result.is_err());
}
#[test]
fn fingerprint_stable() {
let (_sk, pk) = RecipientPrivateKey::generate();
let f1 = fingerprint(&pk);
let f2 = fingerprint(&pk);
assert_eq!(f1, f2);
assert!(f1.starts_with("sha256:"));
}
#[test]
fn pubkey_base64_roundtrip() {
let (_sk, pk) = RecipientPrivateKey::generate();
let s = pk.to_base64();
let pk2 = RecipientPublicKey::from_base64(&s).expect("decode");
assert_eq!(pk, pk2);
}
}