crypto.rs raw

use chacha20poly1305::{
    AeadCore as _, ChaCha20Poly1305, KeyInit as _,
    aead::{Aead as _, OsRng, Payload, rand_core::RngCore as _},
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::Error;

const NONCE_BYTES: usize = 12;

/// Identifies a chunk by a keyed hash (HMAC-SHA256) of its plaintext.
///
/// Using a keyed hash rather than a plain digest means identical content
/// deduplicates within a repository, while someone holding the repository
/// cannot test whether it contains a known plaintext.
#[derive(Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ChunkId([u8; 32]);

impl ChunkId {
    pub(crate) fn compute(id_key: &[u8; 32], plaintext: &[u8]) -> ChunkId {
        let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(id_key)
            .expect("HMAC-SHA256 accepts keys of any length");
        mac.update(plaintext);

        ChunkId(mac.finalize().into_bytes().into())
    }

    pub(crate) fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    pub(crate) fn from_bytes(bytes: [u8; 32]) -> ChunkId {
        ChunkId(bytes)
    }

    pub fn to_hex(&self) -> String {
        hex::encode(self.0)
    }

    pub fn from_hex(text: impl AsRef<str>) -> Result<ChunkId, Error> {
        let text = text.as_ref();
        let mut bytes = [0u8; 32];

        hex::decode_to_slice(text, &mut bytes)
            .map_err(|_| Error::InvalidObjectName(text.to_string()))?;

        Ok(ChunkId(bytes))
    }
}

impl std::fmt::Debug for ChunkId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "ChunkId({})", self.to_hex())
    }
}

impl std::fmt::Display for ChunkId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.to_hex())
    }
}

/// The secrets protecting a repository.
///
/// Nothing here is ever stored, anywhere: the keys are re-derived
/// idempotently from the passphrase and the repository's public
/// [`Header`] whenever the repository is opened. Consequently the
/// passphrase *is* the key material — it cannot be changed without
/// re-encrypting everything, and losing it loses the repository.
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct MasterKeys {
    /// ChaCha20-Poly1305 key sealing every object except the header.
    encryption: [u8; 32],
    /// HMAC key from which chunk ids are computed.
    id: [u8; 32],
    /// Key for the header's passphrase verifier.
    verify: [u8; 32],
}

impl MasterKeys {
    /// Derives the key set from a passphrase: Argon2id over the
    /// repository's public salt yields a master secret, and independent
    /// subkeys are split from it by domain-separated HMAC.
    pub(crate) fn derive(password: &str, header: &Header) -> Result<MasterKeys, Error> {
        let argon2 = argon2::Argon2::new(
            argon2::Algorithm::Argon2id,
            argon2::Version::V0x13,
            argon2::Params::new(
                header.argon2_memory_kib,
                header.argon2_iterations,
                header.argon2_parallelism,
                Some(32),
            )
            .map_err(|_| Error::InvalidHeader)?,
        );

        let mut master = [0u8; 32];
        argon2
            .hash_password_into(password.as_bytes(), &header.salt, &mut master)
            .map_err(|_| Error::InvalidHeader)?;

        let keys = MasterKeys {
            encryption: keyed_digest(&master, &[b"beeping encryption key".as_slice()]),
            id: keyed_digest(&master, &[b"beeping chunk id key".as_slice()]),
            verify: keyed_digest(&master, &[b"beeping verify key".as_slice()]),
        };

        master.zeroize();

        Ok(keys)
    }

    /// The public check value proving a later derivation used the same
    /// passphrase *and* that the header it was derived against is the
    /// one the repository was created with. Committing every header
    /// field means a storage-level attacker cannot silently swap in
    /// weaker KDF parameters or different chunker parameters (the
    /// latter would quietly destroy deduplication): any tampering makes
    /// the verifier mismatch, indistinguishable from a wrong passphrase
    /// and equally fatal to the attempt.
    ///
    /// Knowing the verifier helps an attacker no more than any
    /// ciphertext object does: either way they must brute-force the
    /// passphrase through Argon2.
    pub(crate) fn verifier(&self, header: &Header) -> [u8; 32] {
        keyed_digest(
            &self.verify,
            &[
                b"beeping passphrase verifier".as_slice(),
                &header.version.to_be_bytes(),
                &header.salt,
                &header.argon2_memory_kib.to_be_bytes(),
                &header.argon2_iterations.to_be_bytes(),
                &header.argon2_parallelism.to_be_bytes(),
                &header.config.chunk_min.to_be_bytes(),
                &header.config.chunk_avg.to_be_bytes(),
                &header.config.chunk_max.to_be_bytes(),
            ],
        )
    }

    pub(crate) fn id_key(&self) -> &[u8; 32] {
        &self.id
    }
}

/// A keyed digest over several byte strings, used for deriving
/// deterministic identifiers. Each part's length is mixed in, so distinct
/// part lists never collide by concatenation.
pub(crate) fn keyed_digest(key: &[u8; 32], parts: &[&[u8]]) -> [u8; 32] {
    let mut mac =
        <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC-SHA256 accepts keys of any length");

    for part in parts {
        mac.update(&(part.len() as u64).to_be_bytes());
        mac.update(part);
    }

    mac.finalize().into_bytes().into()
}

/// Parameters that must stay constant for the life of a repository so
/// that identical content keeps producing identical chunks.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct RepoConfig {
    pub chunk_min: u32,
    pub chunk_avg: u32,
    pub chunk_max: u32,
}

impl Default for RepoConfig {
    fn default() -> Self {
        RepoConfig {
            chunk_min: 256 * 1024,
            chunk_avg: 1024 * 1024,
            chunk_max: 4 * 1024 * 1024,
        }
    }
}

/// The repository's public, write-once `header` object.
///
/// It deliberately contains no secret material — only what a client
/// needs to re-derive the keys from the passphrase (salt and KDF
/// parameters), detect a wrong passphrase (the verifier), and chunk
/// content compatibly with every other client of the repository.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct Header {
    pub version: u16,
    pub salt: [u8; 16],
    pub argon2_memory_kib: u32,
    pub argon2_iterations: u32,
    pub argon2_parallelism: u32,
    pub verifier: [u8; 32],
    pub config: RepoConfig,
}

pub(crate) const HEADER_VERSION: u16 = 1;

impl Header {
    /// A fresh header with a random salt and current cost parameters.
    /// The verifier starts zeroed and is filled in after the first key
    /// derivation.
    pub(crate) fn generate() -> Header {
        let mut salt = [0u8; 16];
        OsRng.fill_bytes(&mut salt);

        let defaults = argon2::Params::DEFAULT;

        Header {
            version: HEADER_VERSION,
            salt,
            argon2_memory_kib: defaults.m_cost(),
            argon2_iterations: defaults.t_cost(),
            argon2_parallelism: defaults.p_cost(),
            verifier: [0u8; 32],
            config: RepoConfig::default(),
        }
    }
}

/// Encrypts `plaintext` with the repository's master key, producing
/// `nonce || ciphertext`. The AAD binds the result to the object it names,
/// so an object substituted for another fails to open.
pub(crate) fn seal(keys: &MasterKeys, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, Error> {
    let cipher = ChaCha20Poly1305::new((&keys.encryption).into());
    let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);

    let ciphertext = cipher
        .encrypt(
            &nonce,
            Payload {
                msg: plaintext,
                aad,
            },
        )
        .map_err(|_| Error::Crypto)?;

    let mut sealed = Vec::with_capacity(NONCE_BYTES + ciphertext.len());
    sealed.extend_from_slice(&nonce);
    sealed.extend_from_slice(&ciphertext);

    Ok(sealed)
}

/// Reverses [`seal`], authenticating the data and its AAD in the process.
pub(crate) fn open(keys: &MasterKeys, aad: &[u8], sealed: &[u8]) -> Result<Vec<u8>, Error> {
    if sealed.len() < NONCE_BYTES {
        return Err(Error::Crypto);
    }

    let (nonce, ciphertext) = sealed.split_at(NONCE_BYTES);
    let cipher = ChaCha20Poly1305::new((&keys.encryption).into());

    cipher
        .decrypt(
            nonce.into(),
            Payload {
                msg: ciphertext,
                aad,
            },
        )
        .map_err(|_| Error::Crypto)
}

/// Defines a 16-byte object identifier: a newtype over the bytes with
/// hex parsing, display, and the derives every one of them needs.
///
/// Snapshots, prune records, and locks are all named this way, and the
/// distinct types keep one from being passed where another belongs.
macro_rules! object_id {
    ($name:ident, $doc:expr) => {
        #[doc = $doc]
        #[derive(Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
        pub struct $name([u8; 16]);

        impl $name {
            pub(crate) fn from_bytes(bytes: [u8; 16]) -> $name {
                $name(bytes)
            }

            pub fn to_hex(&self) -> String {
                hex::encode(self.0)
            }

            pub fn from_hex(text: impl AsRef<str>) -> Result<$name, crate::Error> {
                let text = text.as_ref();
                let mut bytes = [0u8; 16];

                hex::decode_to_slice(text, &mut bytes)
                    .map_err(|_| crate::Error::InvalidObjectName(text.to_string()))?;

                Ok($name(bytes))
            }

            pub(crate) fn as_bytes(&self) -> &[u8; 16] {
                &self.0
            }
        }

        impl std::fmt::Debug for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}({})", stringify!($name), self.to_hex())
            }
        }

        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                f.write_str(&self.to_hex())
            }
        }
    };
}

/// Gives an id type a source of fresh, unpredictable values.
///
/// Only for the objects named by nothing but themselves. A snapshot's id
/// is derived from what it contains instead, so that a backup redone
/// after an interruption converges on one object rather than piling up
/// duplicates — which is why this is not part of [`object_id`].
macro_rules! random_id {
    ($name:ident) => {
        impl $name {
            pub(crate) fn generate() -> $name {
                let mut bytes = [0u8; 16];
                OsRng.fill_bytes(&mut bytes);

                $name::from_bytes(bytes)
            }
        }
    };
}

object_id!(
    SnapshotId,
    "Identifies a snapshot record within a repository."
);

object_id!(
    PruneId,
    "Identifies the record one prune leaves behind. Their ids, taken \
     together, are what tells a local cache whether anything has been \
     removed from the repository since it was written."
);

object_id!(
    LockId,
    "Identifies one running backup's lock. A lock means the run has \
     references that no snapshot names yet, so pruning would sweep them."
);

random_id!(PruneId);
random_id!(LockId);