use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use prns_core::identity::{Zeroizing, IDENTITY_SECRET_KEY_LEN};
use prns_core::interfaces::bluetooth_auto::{
decode_persisted_ble_identity, encode_persisted_ble_identity, BleIdentity,
PersistedBleIdentityError, PERSISTED_BLE_IDENTITY_LEN,
};
use prns_core::interfaces::browser_rendezvous::{
BrowserRendezvousId, BrowserSelectionSeed, ID_LEN as LOCAL_IDENTITY_LEN,
};
#[must_use]
#[expect(
clippy::expect_used,
reason = "a host without a functioning CSPRNG cannot mint identities; failing loud beats weak keys"
)]
pub fn generate_identity_secret() -> Zeroizing<[u8; IDENTITY_SECRET_KEY_LEN]> {
try_generate_identity_secret().expect("OS CSPRNG must provide identity key material")
}
pub fn try_generate_identity_secret(
) -> Result<Zeroizing<[u8; IDENTITY_SECRET_KEY_LEN]>, OsEntropyError> {
let mut key = Zeroizing::new([0u8; IDENTITY_SECRET_KEY_LEN]);
fill_os_entropy(&mut *key)?;
Ok(key)
}
pub fn fill_os_entropy(bytes: &mut [u8]) -> Result<(), OsEntropyError> {
getrandom::getrandom(bytes).map_err(OsEntropyError)
}
pub fn load_or_create_ble_identity(path: &Path) -> Result<BleIdentity, LocalIdentityFileError> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return create_ble_identity(path)
}
Err(error) => return Err(LocalIdentityFileError::Io(error)),
};
let len = file.metadata().map_err(LocalIdentityFileError::Io)?.len();
if len != PERSISTED_BLE_IDENTITY_LEN as u64 {
return Err(LocalIdentityFileError::Malformed {
len,
expected: PERSISTED_BLE_IDENTITY_LEN,
});
}
let mut record = Zeroizing::new([0u8; PERSISTED_BLE_IDENTITY_LEN]);
file.read_exact(&mut record[..])
.map_err(LocalIdentityFileError::Io)?;
decode_persisted_ble_identity(&record)
.map_err(LocalIdentityFileError::InvalidBleIdentity)?
.ok_or(LocalIdentityFileError::EmptyBleIdentity)
}
pub fn load_or_create_browser_rendezvous_id(
path: &Path,
) -> Result<BrowserRendezvousId, LocalIdentityFileError> {
load_or_create_local_identity(path).map(BrowserRendezvousId::new)
}
pub fn load_or_create_browser_selection_seed(
path: &Path,
) -> Result<BrowserSelectionSeed, LocalIdentityFileError> {
load_or_create_local_identity(path).map(BrowserSelectionSeed::new)
}
fn load_or_create_local_identity(
path: &Path,
) -> Result<[u8; LOCAL_IDENTITY_LEN], LocalIdentityFileError> {
match fs::File::open(path) {
Ok(mut file) => read_local_identity(&mut file),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => create_local_identity(path),
Err(error) => Err(LocalIdentityFileError::Io(error)),
}
}
fn read_local_identity(
file: &mut fs::File,
) -> Result<[u8; LOCAL_IDENTITY_LEN], LocalIdentityFileError> {
let len = file.metadata().map_err(LocalIdentityFileError::Io)?.len();
if len != LOCAL_IDENTITY_LEN as u64 {
return Err(LocalIdentityFileError::Malformed {
len,
expected: LOCAL_IDENTITY_LEN,
});
}
let mut bytes = [0u8; LOCAL_IDENTITY_LEN];
file.read_exact(&mut bytes)
.map_err(LocalIdentityFileError::Io)?;
Ok(bytes)
}
fn create_ble_identity(path: &Path) -> Result<BleIdentity, LocalIdentityFileError> {
let mut bytes = [0u8; LOCAL_IDENTITY_LEN];
fill_os_entropy(&mut bytes).map_err(LocalIdentityFileError::Entropy)?;
let identity = BleIdentity::new(bytes);
let record = Zeroizing::new(encode_persisted_ble_identity(identity));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(LocalIdentityFileError::Io)?;
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(path) {
Ok(mut file) => {
let stored = file
.write_all(&record[..])
.and_then(|()| file.sync_all())
.map_err(LocalIdentityFileError::Io);
if let Err(error) = stored {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
Ok(identity)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
load_or_create_ble_identity(path)
}
Err(error) => Err(LocalIdentityFileError::Io(error)),
}
}
fn create_local_identity(path: &Path) -> Result<[u8; LOCAL_IDENTITY_LEN], LocalIdentityFileError> {
let mut bytes = [0u8; LOCAL_IDENTITY_LEN];
fill_os_entropy(&mut bytes).map_err(LocalIdentityFileError::Entropy)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(LocalIdentityFileError::Io)?;
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(path) {
Ok(mut file) => {
file.write_all(&bytes).map_err(LocalIdentityFileError::Io)?;
file.sync_all().map_err(LocalIdentityFileError::Io)?;
Ok(bytes)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let mut file = fs::File::open(path).map_err(LocalIdentityFileError::Io)?;
read_local_identity(&mut file)
}
Err(error) => Err(LocalIdentityFileError::Io(error)),
}
}
pub fn load_or_create_identity_secret(
path: &Path,
) -> Result<Zeroizing<[u8; IDENTITY_SECRET_KEY_LEN]>, IdentitySecretFileError> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return create_identity_secret(path)
}
Err(error) => return Err(IdentitySecretFileError::Io(error)),
};
let len = file.metadata().map_err(IdentitySecretFileError::Io)?.len();
if len != IDENTITY_SECRET_KEY_LEN as u64 {
return Err(IdentitySecretFileError::Malformed { len });
}
let mut key = Zeroizing::new([0u8; IDENTITY_SECRET_KEY_LEN]);
file.read_exact(&mut *key)
.map_err(IdentitySecretFileError::Io)?;
Ok(key)
}
fn create_identity_secret(
path: &Path,
) -> Result<Zeroizing<[u8; IDENTITY_SECRET_KEY_LEN]>, IdentitySecretFileError> {
let key = generate_identity_secret();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(IdentitySecretFileError::Io)?;
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(path) {
Ok(mut file) => {
let stored = file
.write_all(&key[..])
.and_then(|()| file.sync_all())
.map_err(IdentitySecretFileError::Io);
if let Err(error) = stored {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
Ok(key)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
load_or_create_identity_secret(path)
}
Err(error) => Err(IdentitySecretFileError::Io(error)),
}
}
#[derive(Debug)]
pub enum IdentitySecretFileError {
Io(std::io::Error),
Malformed { len: u64 },
}
#[derive(Debug)]
pub enum LocalIdentityFileError {
Io(std::io::Error),
Malformed { len: u64, expected: usize },
EmptyBleIdentity,
InvalidBleIdentity(PersistedBleIdentityError),
Entropy(OsEntropyError),
}
#[derive(Debug)]
pub struct OsEntropyError(getrandom::Error);
impl core::fmt::Display for OsEntropyError {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(formatter, "OS CSPRNG failed: {}", self.0)
}
}
impl std::error::Error for OsEntropyError {}
impl core::fmt::Display for IdentitySecretFileError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
IdentitySecretFileError::Io(error) => write!(f, "identity secret file: {error}"),
IdentitySecretFileError::Malformed { len } => write!(
f,
"identity secret file holds {len} bytes, not the {IDENTITY_SECRET_KEY_LEN} of an X25519 ‖ Ed25519 secret"
),
}
}
}
impl std::error::Error for IdentitySecretFileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
IdentitySecretFileError::Io(error) => Some(error),
IdentitySecretFileError::Malformed { .. } => None,
}
}
}
impl core::fmt::Display for LocalIdentityFileError {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Io(error) => write!(formatter, "local identity file: {error}"),
Self::Malformed { len, expected } => write!(
formatter,
"local identity file holds {len} bytes, not {expected}"
),
Self::EmptyBleIdentity => formatter.write_str("BLE identity file is empty"),
Self::InvalidBleIdentity(error) => error.fmt(formatter),
Self::Entropy(error) => error.fmt(formatter),
}
}
}
impl std::error::Error for LocalIdentityFileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::Malformed { .. } | Self::EmptyBleIdentity => None,
Self::InvalidBleIdentity(error) => Some(error),
Self::Entropy(error) => Some(error),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_path_mints_persists_and_reloads_the_same_secret() {
let dir =
std::env::temp_dir().join(format!("prns-identity-bootstrap-{}", std::process::id()));
let path = dir.join("deeper").join("transport_identity");
let _ = fs::remove_dir_all(&dir);
let minted = load_or_create_identity_secret(&path).unwrap();
let reloaded = load_or_create_identity_secret(&path).unwrap();
assert_eq!(&minted[..], &reloaded[..]);
assert_ne!(&minted[..], &[0u8; IDENTITY_SECRET_KEY_LEN][..]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_malformed_file_is_refused_not_overwritten() {
let dir = std::env::temp_dir().join(format!(
"prns-identity-bootstrap-malformed-{}",
std::process::id()
));
let path = dir.join("transport_identity");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(&path, b"short").unwrap();
assert!(matches!(
load_or_create_identity_secret(&path),
Err(IdentitySecretFileError::Malformed { len: 5 })
));
assert_eq!(fs::read(&path).unwrap(), b"short");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn generated_secrets_differ() {
assert_ne!(
&generate_identity_secret()[..],
&generate_identity_secret()[..],
);
}
#[test]
fn local_service_identities_are_stable_and_independent() {
let dir =
std::env::temp_dir().join(format!("prns-local-identities-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let ble_path = dir.join("ble_identity");
let browser_path = dir.join("browser_rendezvous_id");
let selection_path = dir.join("browser_selection_seed");
let transport_path = dir.join("transport_identity");
let ble = load_or_create_ble_identity(&ble_path).unwrap();
let browser = load_or_create_browser_rendezvous_id(&browser_path).unwrap();
let selection = load_or_create_browser_selection_seed(&selection_path).unwrap();
let transport = load_or_create_identity_secret(&transport_path).unwrap();
assert_eq!(load_or_create_ble_identity(&ble_path).unwrap(), ble);
assert_eq!(
load_or_create_browser_rendezvous_id(&browser_path).unwrap(),
browser
);
assert_eq!(
load_or_create_browser_selection_seed(&selection_path).unwrap(),
selection
);
assert_ne!(ble.as_bytes(), browser.as_bytes());
assert_ne!(ble.as_bytes(), selection.as_bytes());
assert_ne!(browser.as_bytes(), selection.as_bytes());
assert_ne!(ble.as_bytes().as_slice(), &transport[..LOCAL_IDENTITY_LEN]);
assert_ne!(
browser.as_bytes().as_slice(),
&transport[..LOCAL_IDENTITY_LEN]
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn malformed_local_service_identities_are_refused() {
let dir = std::env::temp_dir().join(format!(
"prns-local-identities-malformed-{}",
std::process::id()
));
let path = dir.join("ble_identity");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(&path, [0u8; LOCAL_IDENTITY_LEN - 1]).unwrap();
assert!(matches!(
load_or_create_ble_identity(&path),
Err(LocalIdentityFileError::Malformed { len, .. })
if len == (LOCAL_IDENTITY_LEN - 1) as u64
));
assert_eq!(fs::read(&path).unwrap(), [0u8; LOCAL_IDENTITY_LEN - 1]);
let _ = fs::remove_dir_all(&dir);
}
}