Skip to content
Snippets Groups Projects
Verified Commit 311a7337 authored by Wiktor Kwapisiewicz's avatar Wiktor Kwapisiewicz
Browse files

feat: Add `nethsm-backup` library

Fixes: #52


Signed-off-by: default avatarWiktor Kwapisiewicz <wiktor@metacode.biz>
parent ebb0aa75
No related branches found
No related tags found
No related merge requests found
......@@ -400,9 +400,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.19"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615"
checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
dependencies = [
"clap_builder",
"clap_derive",
......@@ -410,9 +410,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.19"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b"
checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54"
dependencies = [
"anstream",
"anstyle",
......@@ -1583,6 +1583,22 @@ dependencies = [
"uuid",
]
[[package]]
name = "nethsm-backup"
version = "0.1.0"
dependencies = [
"aes-gcm",
"nethsm",
"nethsm-tests",
"rstest",
"rustainers",
"scrypt",
"testdir",
"testresult",
"thiserror 2.0.0",
"tokio",
]
[[package]]
name = "nethsm-cli"
version = "0.3.0"
......@@ -1907,6 +1923,16 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
......@@ -2497,6 +2523,15 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.26"
......@@ -2506,6 +2541,18 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"password-hash",
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "sec1"
version = "0.7.3"
......
......@@ -2,6 +2,7 @@
resolver = "2"
members = [
"nethsm",
"nethsm-backup",
"nethsm-cli",
"nethsm-config",
"nethsm-tests",
......@@ -34,3 +35,11 @@ repository = "https://gitlab.archlinux.org/archlinux/signstar"
lto = true
codegen-units = 1
opt-level = "z"
# Enable optimizations for crates that are extremely slow unoptimized
# scrypt opt-level provides 30x increase of performance, while aes-gcm 2x
[profile.dev.package.scrypt]
opt-level = 3
[profile.dev.package.aes-gcm]
opt-level = 3
[package]
name = "nethsm-backup"
version = "0.1.0"
description = "A library and binary for working with encrypted NetHSM backups"
keywords = ["encryption", "hsm", "nethsm", "backup"]
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
aes-gcm = "0.10.3"
scrypt = "0.11.0"
thiserror.workspace = true
[dev-dependencies]
nethsm = { path = "../nethsm" }
nethsm-tests = { path = "../nethsm-tests" }
rstest.workspace = true
rustainers = "0.13.1"
testdir.workspace = true
testresult = "0.4.1"
tokio = { version = "1.41.0", features = ["macros"] }
# NetHSM backup
A library to parse, decrypt, validate and browse NetHSM backups.
## Examples
Listing all fields in a backup file:
```rust no_run
# fn main() -> testresult::TestResult {
use std::collections::HashMap;
use nethsm_backup::Backup;
let backup = Backup::parse(std::fs::File::open("tests/nethsm.backup-file.bkp")?)?;
let decryptor = backup.decrypt(b"my-very-unsafe-backup-passphrase")?;
assert_eq!(decryptor.version()?, [0]);
for item in decryptor.items_iter() {
let (key, value) = item?;
println!("Found {key} with value: {value:X?}");
}
# Ok(()) }
```
Dumping the value of one specified field (here `/config/version`):
```rust no_run
# fn main() -> testresult::TestResult {
use std::collections::HashMap;
use nethsm_backup::Backup;
let backup = Backup::parse(std::fs::File::open("tests/nethsm.backup-file.bkp")?)?;
let decryptor = backup.decrypt(b"my-very-unsafe-backup-passphrase")?;
assert_eq!(decryptor.version()?, [0]);
for (key, value) in decryptor
.items_iter()
.flat_map(|item| item.ok())
.filter(|(key, _)| key == "/config/version")
{
println!("Found {key} with value: {value:X?}");
}
# Ok(()) }
```
#![doc = include_str!("../README.md")]
use std::{
io::{ErrorKind, Read},
slice::Iter,
};
use aes_gcm::{aead::Aead as _, Aes256Gcm, KeyInit as _};
use scrypt::{scrypt, Params};
/// Backup processing error.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// I/O error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// Scrypt key derivation error.
#[error("Scrypt error")]
Scrypt,
/// AES-GCM decryption error.
#[error("AES-GCM decryption error")]
Decryption,
/// Unicode decode error.
#[error("Key is not a valid UTF-8: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
/// Magic value is incorrect.
///
/// This file is either corrupted or not a NetHSM backup.
#[error("Bad magic value: {0:X?}")]
BadMagic(Vec<u8>),
/// Version number is not recognized.
///
/// This library supports only version `0` backups.
#[error("Bad version number: {0}")]
BadVersion(u8),
}
type Result<T> = std::result::Result<T, Error>;
/// Magic value that is contained in all NetHSM backups.
const MAGIC: &[u8] = b"_NETHSM_BACKUP_";
/// Read 3 bytes from the provided reader and interprets it as a [usize].
fn read_usize(reader: &mut impl Read) -> std::io::Result<usize> {
let mut len: [u8; 3] = [0; 3];
reader.read_exact(&mut len)?;
let len0 = (len[0] as usize) << 16;
let len1 = (len[1] as usize) << 8;
let len2 = len[2] as usize;
let len = len0 + len1 + len2;
Ok(len)
}
/// Read a byte vector from the underlying reader.
///
/// A byte vector is always stored as a [usize] (see [read_usize]) and
/// then a number of bytes.
fn read_field(reader: &mut impl Read) -> Result<Vec<u8>> {
let len = read_usize(reader)?;
let mut field = vec![0; len];
reader.read_exact(&mut field)?;
Ok(field)
}
/// Check if the reader contains correct [MAGIC] value and returns
/// [Error::BadMagic] if not.
fn check_magic(reader: &mut impl Read) -> Result<()> {
let mut magic = [0; MAGIC.len()];
reader.read_exact(&mut magic)?;
if MAGIC != magic {
return Err(Error::BadMagic(magic.into()));
}
Ok(())
}
/// Check if the reader contains version number that is understood
/// (currently only `0`) and returns [Error::BadVersion] if not.
fn check_version(reader: &mut impl Read) -> Result<()> {
let mut version = [0; 1];
reader.read_exact(&mut version)?;
let version = version[0];
if version != 0 {
return Err(Error::BadVersion(version));
}
Ok(())
}
/// Parsed backup.
///
/// This object contains backup which is well-formed and has been parsed.
#[derive(Debug)]
pub struct Backup {
salt: Vec<u8>,
encrypted_version: Vec<u8>,
encrypted_domain_key: Vec<u8>,
items: Vec<Vec<u8>>,
}
impl Backup {
/// Parse the backup from a reader.
pub fn parse(mut reader: impl Read) -> Result<Self> {
check_magic(&mut reader)?;
check_version(&mut reader)?;
let salt = read_field(&mut reader)?;
let encrypted_version = read_field(&mut reader)?;
let encrypted_domain_key = read_field(&mut reader)?;
let mut items = vec![];
loop {
match read_usize(&mut reader) {
Ok(len) => {
let mut field = vec![0; len];
reader.read_exact(&mut field)?;
items.push(field);
}
Err(error) if error.kind() == ErrorKind::UnexpectedEof => {
break;
}
Err(error) => {
return Err(error)?;
}
}
}
Ok(Self {
salt,
encrypted_version,
encrypted_domain_key,
items,
})
}
/// Create a decryptor that will decrypt items with the provided password.
pub fn decrypt(&self, password: &[u8]) -> Result<BackupDecryptor> {
BackupDecryptor::new(self, password)
}
}
/// Backup decryptor which decrypts backup items on the fly.
pub struct BackupDecryptor<'a> {
backup: &'a Backup,
cipher: Aes256Gcm,
}
impl<'a> BackupDecryptor<'a> {
/// Create a new decryptor with given password.
///
/// Even though this function returns a `Result` it is unlikely to
/// fail since all parameters are static.
fn new(backup: &'a Backup, password: &[u8]) -> Result<Self> {
let mut key = [0; 32];
scrypt(
password,
&backup.salt,
&Params::new(14, 8, 16, 32).map_err(|_| Error::Scrypt)?,
&mut key,
)
.map_err(|_| Error::Scrypt)?;
let cipher = Aes256Gcm::new(&key.into());
Ok(Self { backup, cipher })
}
/// Decrypts a piece of data.
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
let (nonce, msg) = ciphertext.split_at(12);
let payload = aes_gcm::aead::Payload { msg, aad };
let plaintext = self
.cipher
.decrypt(nonce.into(), payload)
.map_err(|_| Error::Decryption)?;
Ok(plaintext)
}
/// Decrypted backup version.
pub fn version(&self) -> Result<Vec<u8>> {
self.decrypt(&self.backup.encrypted_version, b"backup-version")
}
/// Decrypted domain key.
pub fn domain_key(&self) -> Result<Vec<u8>> {
self.decrypt(&self.backup.encrypted_domain_key, b"domain-key")
}
/// Returns an iterator over backup entries.
pub fn items_iter(&'a self) -> impl Iterator<Item = Result<(String, Vec<u8>)>> + 'a {
BackupItemDecryptor {
decryptor: self,
inner: self.backup.items.iter(),
}
}
}
struct BackupItemDecryptor<'a> {
decryptor: &'a BackupDecryptor<'a>,
inner: Iter<'a, Vec<u8>>,
}
impl Iterator for BackupItemDecryptor<'_> {
type Item = Result<(String, Vec<u8>)>;
/// Return next pair of key and value.
fn next(&mut self) -> Option<Self::Item> {
self.inner.next().map(|item| {
let decrypted = self.decryptor.decrypt(item, b"backup")?;
let mut reader = std::io::Cursor::new(decrypted);
let key = String::from_utf8(read_field(&mut reader)?)?;
let mut value = vec![];
reader.read_to_end(&mut value)?;
Ok((key, value))
})
}
}
use nethsm::{NetHsm, Passphrase, UserId};
use nethsm_backup::Backup;
use nethsm_tests::{nethsm_with_users, NetHsmImage, ADMIN_USER_ID, BACKUP_USER_ID};
use rstest::rstest;
use rustainers::Container;
use testdir::testdir;
use testresult::TestResult;
#[ignore = "requires Podman"]
#[rstest]
#[tokio::test]
async fn create_backup_and_decrypt_it(
#[future] nethsm_with_users: TestResult<(NetHsm, Container<NetHsmImage>)>,
) -> TestResult {
let (nethsm, _container) = nethsm_with_users.await?;
let backup_file = testdir!().join("nethsm-backup");
let initial_passphrase = "";
let new_backup_passphrase = "just-a-backup-passphrase";
let admin_user_id: UserId = ADMIN_USER_ID.parse()?;
let backup_user_id: UserId = BACKUP_USER_ID.parse()?;
// users in Backup role can receive backups
nethsm.use_credentials(&backup_user_id)?;
// fails because the backup passphrase is not yet set!
assert!(nethsm.backup().is_err());
// set backup passphrase
nethsm.use_credentials(&admin_user_id)?;
nethsm.set_backup_passphrase(
Passphrase::new(initial_passphrase.to_string()),
Passphrase::new(new_backup_passphrase.to_string()),
)?;
nethsm.use_credentials(&backup_user_id)?;
// write backup file
let backup = nethsm.backup()?;
std::fs::write(&backup_file, backup.clone())?;
println!("Written NetHSM backup file: {:?}", &backup_file);
let backup = Backup::parse(std::fs::File::open(&backup_file)?)?;
let backup = backup.decrypt(new_backup_passphrase.as_bytes())?;
assert_eq!(backup.version()?, [0]);
for item in backup.items_iter() {
let key = item?.0;
println!("{key}");
}
Ok(())
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment