384 lines
12 KiB
Rust
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);
|
|
}
|
|
}
|