dotlock-bin 0.1.1

Encrypted project-local environment variables manager
use std::path::Path;

use base64::{Engine, engine::general_purpose};
use chacha20poly1305::{
    XChaCha20Poly1305, XNonce,
    aead::{Aead, KeyInit, Payload},
};
use sha2::{Digest, Sha256};

use crate::{
    crypto::VaultKeyMetadata,
    domain::{error::DotLockError, model::DotLockResult},
    storage::secure_fs,
};

const HASH_LEN: usize = 32;
const NONCE_LEN: usize = 24;
const HASH_AAD: &[u8] = b"dotlock:v1:secrets-hash";

pub fn compute_file_sha256<P: AsRef<Path>>(path: P) -> DotLockResult<[u8; HASH_LEN]> {
    let path = path.as_ref();
    secure_fs::reject_symlink(path)?;

    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
        Err(err) => return Err(DotLockError::from(err)),
    };

    let digest = Sha256::digest(&bytes);
    let mut out = [0u8; HASH_LEN];
    out.copy_from_slice(&digest);
    Ok(out)
}

pub struct EncryptedHash {
    pub nonce: [u8; NONCE_LEN],
    pub ciphertext: Vec<u8>,
}

pub fn encrypt_hash(hash: &[u8; HASH_LEN], dek: &[u8; 32]) -> DotLockResult<EncryptedHash> {
    let mut nonce = [0u8; NONCE_LEN];
    getrandom::fill(&mut nonce).map_err(|e| DotLockError::Crypto(e.to_string()))?;

    let cipher = XChaCha20Poly1305::new(dek.into());
    let ciphertext = cipher
        .encrypt(
            XNonce::from_slice(&nonce),
            Payload {
                msg: hash,
                aad: HASH_AAD,
            },
        )
        .map_err(|e| DotLockError::Crypto(e.to_string()))?;

    Ok(EncryptedHash { nonce, ciphertext })
}

pub fn decrypt_hash(
    nonce: &[u8; NONCE_LEN],
    ciphertext: &[u8],
    dek: &[u8; 32],
) -> DotLockResult<[u8; HASH_LEN]> {
    let cipher = XChaCha20Poly1305::new(dek.into());
    let plaintext = cipher
        .decrypt(
            XNonce::from_slice(nonce),
            Payload {
                msg: ciphertext,
                aad: HASH_AAD,
            },
        )
        .map_err(|_| DotLockError::TamperedSecretsFile)?;

    let hash: [u8; HASH_LEN] = plaintext
        .try_into()
        .map_err(|_| DotLockError::TamperedSecretsFile)?;
    Ok(hash)
}

pub fn verify_secrets_integrity<P: AsRef<Path>>(
    secrets_path: P,
    metadata: &VaultKeyMetadata,
    dek: &[u8; 32],
) -> DotLockResult<()> {
    if metadata.secrets_hash_b64.is_empty() || metadata.secrets_hash_nonce_b64.is_empty() {
        return Err(DotLockError::LegacyVaultFormat);
    }

    let nonce_bytes = general_purpose::STANDARD
        .decode(&metadata.secrets_hash_nonce_b64)
        .map_err(|_| DotLockError::LegacyVaultFormat)?;
    let nonce: [u8; NONCE_LEN] = nonce_bytes
        .try_into()
        .map_err(|_| DotLockError::LegacyVaultFormat)?;

    let ciphertext = general_purpose::STANDARD
        .decode(&metadata.secrets_hash_b64)
        .map_err(|_| DotLockError::LegacyVaultFormat)?;

    let stored = decrypt_hash(&nonce, &ciphertext, dek)?;
    let current = compute_file_sha256(secrets_path)?;

    if stored != current {
        return Err(DotLockError::TamperedSecretsFile);
    }
    Ok(())
}

pub fn build_encrypted_hash_fields(
    secrets_path: impl AsRef<Path>,
    dek: &[u8; 32],
) -> DotLockResult<(String, String)> {
    let hash = compute_file_sha256(secrets_path)?;
    let encrypted = encrypt_hash(&hash, dek)?;
    Ok((
        general_purpose::STANDARD.encode(encrypted.nonce),
        general_purpose::STANDARD.encode(encrypted.ciphertext),
    ))
}