gloves 0.5.11

seamless secret manager and handoff
Documentation
mod common;

use std::io::Write;
use std::os::unix::fs::PermissionsExt;

use common::generate_identity;
use gloves::{
    agent::backend::AgentBackend,
    error::GlovesError,
    types::{SecretId, SecretValue},
};

#[test]
fn encrypt_decrypt_roundtrip() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/password").unwrap();

    let identity = generate_identity(temp_dir.path(), "agent-a");
    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"hunter2".to_vec()),
            vec![identity.recipient],
        )
        .unwrap();

    let decrypted = backend
        .decrypt(&secret_id, identity.identity_file.as_path())
        .unwrap();
    let value = decrypted.expose(|bytes| bytes.to_vec());
    assert_eq!(value, b"hunter2");
}

#[test]
fn decrypt_wrong_key_fails() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/password").unwrap();

    let identity = generate_identity(temp_dir.path(), "agent-a");
    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"hunter2".to_vec()),
            vec![identity.recipient],
        )
        .unwrap();

    let outsider = generate_identity(temp_dir.path(), "agent-b");
    assert!(backend
        .decrypt(&secret_id, outsider.identity_file.as_path())
        .is_err());
}

#[test]
fn multi_recipient_both_decrypt() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("shared").unwrap();

    let identity_a = generate_identity(temp_dir.path(), "agent-a");
    let identity_b = generate_identity(temp_dir.path(), "agent-b");

    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"top-secret".to_vec()),
            vec![identity_a.recipient.clone(), identity_b.recipient.clone()],
        )
        .unwrap();

    let value_a = backend
        .decrypt(&secret_id, identity_a.identity_file.as_path())
        .unwrap();
    let value_b = backend
        .decrypt(&secret_id, identity_b.identity_file.as_path())
        .unwrap();
    assert_eq!(value_a.expose(|bytes| bytes.to_vec()), b"top-secret");
    assert_eq!(value_b.expose(|bytes| bytes.to_vec()), b"top-secret");
}

#[test]
fn multi_recipient_outsider_fails() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("shared").unwrap();

    let identity_a = generate_identity(temp_dir.path(), "agent-a");
    let identity_b = generate_identity(temp_dir.path(), "agent-b");

    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"top-secret".to_vec()),
            vec![identity_a.recipient, identity_b.recipient],
        )
        .unwrap();

    let outsider = generate_identity(temp_dir.path(), "outsider");
    assert!(backend
        .decrypt(&secret_id, outsider.identity_file.as_path())
        .is_err());
}

#[test]
fn encrypt_creates_age_file() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/password").unwrap();

    let identity = generate_identity(temp_dir.path(), "agent-a");
    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"hunter2".to_vec()),
            vec![identity.recipient],
        )
        .unwrap();

    assert!(backend.ciphertext_path(&secret_id).exists());
}

#[test]
fn encrypt_no_overwrite() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/password").unwrap();

    let identity = generate_identity(temp_dir.path(), "agent-a");
    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"hunter2".to_vec()),
            vec![identity.recipient.clone()],
        )
        .unwrap();

    assert!(backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"other".to_vec()),
            vec![identity.recipient],
        )
        .is_err());
}

#[test]
fn grant_adds_recipient() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("shared").unwrap();

    let creator = generate_identity(temp_dir.path(), "creator");
    let new_user = generate_identity(temp_dir.path(), "new-user");

    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"rotate-me".to_vec()),
            vec![creator.recipient.clone()],
        )
        .unwrap();
    assert!(backend
        .decrypt(&secret_id, new_user.identity_file.as_path())
        .is_err());

    backend
        .grant(
            &secret_id,
            creator.identity_file.as_path(),
            vec![creator.recipient, new_user.recipient],
        )
        .unwrap();

    let decrypted = backend
        .decrypt(&secret_id, new_user.identity_file.as_path())
        .unwrap();
    assert_eq!(decrypted.expose(|bytes| bytes.to_vec()), b"rotate-me");
}

#[test]
fn file_permissions_0600() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/password").unwrap();

    let identity = generate_identity(temp_dir.path(), "agent-a");
    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"hunter2".to_vec()),
            vec![identity.recipient],
        )
        .unwrap();

    let mode = std::fs::metadata(backend.ciphertext_path(&secret_id))
        .unwrap()
        .permissions()
        .mode()
        & 0o777;
    assert_eq!(mode, 0o600);
}

#[test]
fn encrypt_without_recipients_fails() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/password").unwrap();

    assert!(backend
        .encrypt(&secret_id, &SecretValue::new(b"hunter2".to_vec()), vec![])
        .is_err());
}

#[test]
fn ciphertext_checksum_changes_after_grant() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("shared").unwrap();
    let creator = generate_identity(temp_dir.path(), "creator");

    backend
        .encrypt(
            &secret_id,
            &SecretValue::new(b"rotate-me".to_vec()),
            vec![creator.recipient.clone()],
        )
        .unwrap();
    let before = backend.ciphertext_checksum(&secret_id).unwrap();

    let second = generate_identity(temp_dir.path(), "second");
    backend
        .grant(
            &secret_id,
            creator.identity_file.as_path(),
            vec![creator.recipient, second.recipient],
        )
        .unwrap();
    let after = backend.ciphertext_checksum(&secret_id).unwrap();

    assert_ne!(before, after);
}

#[test]
fn delete_missing_is_ok() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/missing").unwrap();
    backend.delete(&secret_id).unwrap();
}

#[test]
fn decrypt_invalid_ciphertext_fails() {
    let temp_dir = tempfile::tempdir().unwrap();
    let backend = AgentBackend::new(temp_dir.path()).unwrap();
    let secret_id = SecretId::new("db/invalid").unwrap();

    let identity = generate_identity(temp_dir.path(), "agent-a");
    let output_path = backend.ciphertext_path(&secret_id);
    std::fs::create_dir_all(output_path.parent().unwrap()).unwrap();
    let mut ciphertext = Vec::new();
    ciphertext.write_all(b"invalid-ciphertext").unwrap();
    std::fs::write(output_path, ciphertext).unwrap();

    assert!(matches!(
        backend.decrypt(&secret_id, identity.identity_file.as_path()),
        Err(GlovesError::Crypto(_))
    ));
}