Files
signer/src/otp_pad.rs
T

384 lines
12 KiB
Rust

//! OTP pad — one-time pad encryption.
//!
//! Port of `otp_pad.c` + `libotppad.c`. One pad per session, bound at
//! startup. Pad offset advances monotonically across requests.
use crate::secure_mem::SecureBuf;
use crate::SignerError;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
/// OTP pad state.
pub struct OtpPadState {
bound: bool,
pads_dir: String,
chksum: String,
pad_path: String,
pad_file: Option<File>,
pad_size: u64,
offset: u64,
scratch: Option<SecureBuf>,
}
impl OtpPadState {
pub fn new() -> Self {
OtpPadState {
bound: false,
pads_dir: String::new(),
chksum: String::new(),
pad_path: String::new(),
pad_file: None,
pad_size: 0,
offset: 0,
scratch: None,
}
}
/// Check if a pad is bound.
pub fn is_bound(&self) -> bool {
self.bound
}
/// Get the pad checksum (64 hex chars).
pub fn chksum(&self) -> Option<&str> {
if self.bound {
Some(&self.chksum)
} else {
None
}
}
/// Get the current pad offset.
pub fn current_offset(&self) -> u64 {
self.offset
}
/// Get the total pad size.
pub fn pad_size(&self) -> u64 {
self.pad_size
}
/// Bind a pad at startup.
///
/// `dir` — directory containing .pad and .state files
/// `spec` — pad checksum (64 hex) or unique prefix
/// `allow_blkback` — allow pads on qvm-block devices (not for production)
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), SignerError> {
// Find the pad file matching the spec
let pad_filename = if spec.len() == 64 {
format!("{}/{}.pad", dir, spec)
} else {
// Prefix match — find a .pad file starting with spec
let entries = std::fs::read_dir(dir)
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let mut found = None;
for entry in entries {
if let Ok(entry) = entry {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(spec) && name_str.ends_with(".pad") {
found = Some(entry.path());
break;
}
}
}
found
.map(|p| p.to_string_lossy().to_string())
.ok_or(SignerError::InvalidInput)?
};
let file = File::open(&pad_filename)
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let metadata = file
.metadata()
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let size = metadata.len();
// Extract checksum from filename
let chksum = std::path::Path::new(&pad_filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// Read offset from .state file
let state_path = format!("{}/{}.state", dir, chksum);
let offset = if let Ok(state_content) = std::fs::read_to_string(&state_path) {
state_content.trim().parse().unwrap_or(0)
} else {
0
};
// Allocate scratch buffer for XOR
let scratch = SecureBuf::alloc(4 * 1024 * 1024) // 4 MB max chunk
.map_err(|_| SignerError::MemoryFailed)?;
self.bound = true;
self.pads_dir = dir.to_string();
self.chksum = chksum;
self.pad_path = pad_filename;
self.pad_file = Some(file);
self.pad_size = size;
self.offset = offset;
self.scratch = Some(scratch);
Ok(())
}
/// Unbind the pad and zeroize scratch buffer.
pub fn unbind(&mut self) {
self.bound = false;
self.pad_file = None;
self.scratch = None; // Drop runs zeroize
self.offset = 0;
self.pad_size = 0;
}
/// Encrypt plaintext using the OTP pad.
///
/// Returns (ciphertext, pad_offset_before, pad_offset_after).
/// TODO: Phase 12 — full ASCII armoring + binary encoding
pub fn encrypt(
&mut self,
plaintext: &[u8],
_encoding: Option<&str>,
) -> Result<(Vec<u8>, u64, u64), SignerError> {
if !self.bound {
return Err(SignerError::InvalidInput);
}
let off_before = self.offset;
let ct = self.xor_with_pad(plaintext)?;
let off_after = self.offset;
Ok((ct, off_before, off_after))
}
/// Decrypt ciphertext using the OTP pad.
///
/// Returns plaintext.
/// TODO: Phase 12 — full ASCII armoring + binary encoding
pub fn decrypt(
&mut self,
ciphertext: &[u8],
_encoding: Option<&str>,
) -> Result<Vec<u8>, SignerError> {
if !self.bound {
return Err(SignerError::InvalidInput);
}
self.xor_with_pad(ciphertext)
}
/// XOR data with pad bytes at the current offset, advancing the offset.
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, SignerError> {
let file = self.pad_file.as_mut().ok_or(SignerError::InvalidInput)?;
let scratch = self.scratch.as_mut().ok_or(SignerError::InvalidInput)?;
let data_len = data.len();
if data_len > scratch.size() {
return Err(SignerError::InvalidInput);
}
// Seek to current offset
file.seek(SeekFrom::Start(self.offset))
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
// Read pad bytes
let pad_slice = &mut scratch.as_mut_slice()[..data_len];
file.read_exact(pad_slice)
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
// XOR
let result: Vec<u8> = data
.iter()
.zip(pad_slice.iter())
.map(|(d, p)| d ^ p)
.collect();
// Zeroize the pad slice we just used
crate::secure_mem::secure_memzero(&mut scratch.as_mut_slice()[..data_len]);
// Advance offset
self.offset += data_len as u64;
// Persist offset to .state file
let state_path = format!("{}/{}.state", self.pads_dir, self.chksum);
let _ = std::fs::write(&state_path, self.offset.to_string());
Ok(result)
}
}
impl Default for OtpPadState {
fn default() -> Self {
Self::new()
}
}
// ── Global OTP pad state ─────────────────────────────────────────────────────
//
// The C version keeps a global `g_otp_pad` that is bound once at startup
// (either via --otp-pad-dir or via the role wizard's OTP preset) and shared
// by the dispatcher for otp_encrypt/otp_decrypt requests. We mirror that with
// a thread-safe global here.
use std::sync::Mutex;
static GLOBAL_OTP_PAD: Mutex<Option<OtpPadState>> = Mutex::new(None);
/// Bind the global OTP pad. Called from the role wizard (OTP preset) or from
/// `--otp-pad-dir` CLI handling. Replaces any previously bound pad.
pub fn bind_global(dir: &str, spec: &str, allow_blkback: bool) -> Result<(), SignerError> {
let mut pad = OtpPadState::new();
pad.bind(dir, spec, allow_blkback)?;
let mut guard = GLOBAL_OTP_PAD.lock().map_err(|e| {
SignerError::Internal(format!("global otp pad lock poisoned: {}", e))
})?;
*guard = Some(pad);
Ok(())
}
/// Check whether the global OTP pad is bound.
pub fn is_global_bound() -> bool {
GLOBAL_OTP_PAD
.lock()
.map(|g| g.as_ref().map(|p| p.is_bound()).unwrap_or(false))
.unwrap_or(false)
}
/// Status string for the global OTP pad, shown on the connections screen.
/// Empty if no pad is bound.
pub fn global_status() -> String {
let guard = match GLOBAL_OTP_PAD.lock() {
Ok(g) => g,
Err(_) => return String::new(),
};
match guard.as_ref() {
Some(p) if p.is_bound() => {
format!(
"OTP pad bound: chksum={} offset={}/{}",
p.chksum().unwrap_or(""),
p.current_offset(),
p.pad_size()
)
}
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_test_pad(dir: &std::path::Path, size: usize) -> String {
std::fs::create_dir_all(dir).unwrap();
// Create a deterministic pad: bytes 0,1,2,...,255,0,1,...
let pad_data: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
let chksum = hex::encode(&nostr_core::crypto::sha256::sha256(&pad_data));
let pad_path = dir.join(format!("{}.pad", chksum));
let mut file = File::create(&pad_path).unwrap();
file.write_all(&pad_data).unwrap();
chksum
}
#[test]
fn test_bind_and_encrypt() {
let dir = std::env::temp_dir().join("signer_otp_test_1");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
assert!(otp.bind(dir.to_str().unwrap(), &chksum, false).is_ok());
assert!(otp.is_bound());
assert_eq!(otp.pad_size(), 1024);
assert_eq!(otp.current_offset(), 0);
let plaintext = b"hello world";
let (ciphertext, off_before, off_after) = otp.encrypt(plaintext, None).unwrap();
assert_eq!(off_before, 0);
assert_eq!(off_after, plaintext.len() as u64);
assert_eq!(ciphertext.len(), plaintext.len());
// XOR with deterministic pad: plaintext[i] ^ (i % 256)
for i in 0..plaintext.len() {
assert_eq!(ciphertext[i], plaintext[i] ^ (i as u8));
}
// Cleanup
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let dir = std::env::temp_dir().join("signer_otp_test_2");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
let plaintext = b"secret message for OTP encryption!";
let (ciphertext, _, _) = otp.encrypt(plaintext, None).unwrap();
// Decrypt: need to seek back to offset 0
// For this test, create a new pad state at offset 0
let mut otp2 = OtpPadState::new();
otp2.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
// Reset offset to 0 by overwriting state file
let state_path = dir.join(format!("{}.state", chksum));
std::fs::write(&state_path, "0").unwrap();
otp2.offset = 0;
let decrypted = otp2.decrypt(&ciphertext, None).unwrap();
assert_eq!(decrypted, plaintext);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_encrypt_without_bind_fails() {
let mut otp = OtpPadState::new();
assert!(otp.encrypt(b"test", None).is_err());
}
#[test]
fn test_unbind() {
let dir = std::env::temp_dir().join("signer_otp_test_3");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
assert!(otp.is_bound());
otp.unbind();
assert!(!otp.is_bound());
assert_eq!(otp.current_offset(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_offset_advances() {
let dir = std::env::temp_dir().join("signer_otp_test_4");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
let (_, off1_before, off1_after) = otp.encrypt(b"first", None).unwrap();
assert_eq!(off1_before, 0);
assert_eq!(off1_after, 5);
let (_, off2_before, off2_after) = otp.encrypt(b"second", None).unwrap();
assert_eq!(off2_before, 5);
assert_eq!(off2_after, 11);
let _ = std::fs::remove_dir_all(&dir);
}
}