armdb 0.12.0

sharded bitcask key-value storage optimized for NVMe
Documentation
//! Per-record AES-256-GCM envelope for the transaction log.
//!
//! # Nonce
//!
//! The nonce is the record's `tx_id`, zero-padded to 12 bytes — *not* its
//! physical position. Positions are reused: a checkpoint with no survivors
//! truncates the active segment back to offset 0 (`txlog/mod.rs`), so
//! `(segment, offset)` would repeat under one key. `tx_id` comes from a durable
//! ceiling and never repeats, not even after a checkpoint deletes every record
//! (`txlog/state.rs`).
//!
//! Because the nonce travels with the record's identity rather than its
//! location, re-sealing a record during copy-forward reproduces the original
//! bytes exactly — the spec's "copy the sealed payload verbatim" requirement,
//! obtained without a nonce field in the format.

use ring::aead::{self, AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};

use crate::error::{DbError, DbResult};

/// Domain separator for the log's HKDF expansion, so the log key can never
/// collide with the per-shard data-file keys derived in `crypto.rs`.
const HKDF_INFO: &[u8] = b"armdb-txlog-v1";

pub(crate) struct RecordCipher {
    key: LessSafeKey,
}

impl RecordCipher {
    pub(crate) fn derive(master: &[u8; 32], salt: &[u8; 16]) -> DbResult<Self> {
        use ring::hkdf::{HKDF_SHA256, Salt};

        let prk = Salt::new(HKDF_SHA256, salt).extract(master);
        let info: [&[u8]; 1] = [HKDF_INFO];
        let okm = prk
            .expand(&info, HKDF_SHA256)
            .map_err(|_| DbError::EncryptionError("txlog hkdf expand failed".into()))?;
        let mut key = [0u8; 32];
        okm.fill(&mut key)
            .map_err(|_| DbError::EncryptionError("txlog hkdf fill failed".into()))?;
        let unbound = UnboundKey::new(&AES_256_GCM, &key)
            .map_err(|_| DbError::EncryptionError("invalid txlog key".into()))?;
        Ok(Self {
            key: LessSafeKey::new(unbound),
        })
    }

    /// Seal `plaintext`, authenticating `aad`. Returns `ciphertext || tag`.
    pub(crate) fn seal(&self, tx_id: u64, aad: &[u8], plaintext: &[u8]) -> DbResult<Vec<u8>> {
        let mut buf = plaintext.to_vec();
        let tag = self
            .key
            .seal_in_place_separate_tag(nonce_for(tx_id), Aad::from(aad), &mut buf)
            .map_err(|_| DbError::EncryptionError("txlog record encryption failed".into()))?;
        buf.extend_from_slice(tag.as_ref());
        Ok(buf)
    }

    pub(crate) fn open(&self, tx_id: u64, aad: &[u8], sealed: &[u8]) -> DbResult<Vec<u8>> {
        let mut buf = sealed.to_vec();
        let plaintext = self
            .key
            .open_in_place(nonce_for(tx_id), Aad::from(aad), &mut buf)
            .map_err(|_| {
                DbError::EncryptionError(format!(
                    "txlog record {tx_id}: decryption/authentication failed"
                ))
            })?;
        Ok(plaintext.to_vec())
    }
}

fn nonce_for(tx_id: u64) -> Nonce {
    let mut bytes = [0u8; aead::NONCE_LEN];
    bytes[..8].copy_from_slice(&tx_id.to_le_bytes());
    Nonce::assume_unique_for_key(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn txlog_cipher_round_trips_under_the_same_tx_id() {
        let c = RecordCipher::derive(&[0x11; 32], &[0x22; 16]).unwrap();
        let sealed = c.seal(7, b"header", b"payload bytes").unwrap();
        assert_ne!(sealed, b"payload bytes");
        assert_eq!(c.open(7, b"header", &sealed).unwrap(), b"payload bytes");
    }

    #[test]
    fn txlog_cipher_rejects_a_different_tx_id_key_or_aad() {
        let c = RecordCipher::derive(&[0x11; 32], &[0x22; 16]).unwrap();
        let sealed = c.seal(7, b"header", b"payload").unwrap();
        assert!(c.open(8, b"header", &sealed).is_err(), "nonce is the tx_id");
        assert!(
            c.open(7, b"other!", &sealed).is_err(),
            "header is authenticated"
        );

        let other_key = RecordCipher::derive(&[0x33; 32], &[0x22; 16]).unwrap();
        assert!(other_key.open(7, b"header", &sealed).is_err());
        let other_salt = RecordCipher::derive(&[0x11; 32], &[0x44; 16]).unwrap();
        assert!(other_salt.open(7, b"header", &sealed).is_err());
    }

    #[test]
    fn txlog_cipher_is_deterministic_for_one_tx_id() {
        // Copy-forward re-seals the same plaintext under the same tx_id: the
        // bytes must come out identical, which is what makes "copy the sealed
        // record verbatim" and "re-encode it" the same operation.
        let c = RecordCipher::derive(&[0x11; 32], &[0x22; 16]).unwrap();
        assert_eq!(
            c.seal(9, b"h", b"p").unwrap(),
            c.seal(9, b"h", b"p").unwrap()
        );
    }

    #[test]
    fn txlog_cipher_detects_tampering() {
        let c = RecordCipher::derive(&[0x11; 32], &[0x22; 16]).unwrap();
        let mut sealed = c.seal(1, b"h", b"payload").unwrap();
        sealed[0] ^= 0xFF;
        assert!(c.open(1, b"h", &sealed).is_err());
    }
}