Build secure Rust watchOS TOTP core (#56)

This commit is contained in:
2026-08-16 14:27:21 +02:00
parent a055c93b86
commit df1d49339c
20 changed files with 2672 additions and 137 deletions

View File

@@ -5,32 +5,60 @@
//!
//! This crate is the sole owner of stored and derived password-store objects.
#[cfg(feature = "full")]
pub mod authentication;
#[cfg(feature = "full")]
pub mod command;
#[cfg(feature = "full")]
pub mod config;
#[cfg(feature = "full")]
pub mod crypto;
#[cfg(feature = "full")]
pub mod desktop;
#[cfg(feature = "full")]
pub mod document;
#[cfg(feature = "full")]
pub mod generate;
#[cfg(feature = "full")]
pub mod git;
#[cfg(feature = "full")]
pub mod kdbx;
#[cfg(feature = "full")]
pub mod mobile;
#[cfg(feature = "full")]
pub mod mobile_authentication;
#[cfg(feature = "full")]
pub mod mobile_entry;
#[cfg(feature = "full")]
pub mod mobile_home;
#[cfg(feature = "full")]
pub mod mobile_key_transfer;
#[cfg(feature = "full")]
pub mod mobile_mutation;
#[cfg(feature = "full")]
pub mod mobile_onboarding;
#[cfg(feature = "full")]
pub mod mobile_passwords;
#[cfg(feature = "full")]
pub mod mobile_totp;
pub mod mobile_watch;
#[cfg(feature = "full")]
pub mod mutation;
#[cfg(feature = "full")]
pub mod otp;
mod otp_core;
#[cfg(feature = "full")]
pub mod presentation;
#[cfg(feature = "full")]
pub mod read;
#[cfg(feature = "full")]
pub mod recipient;
#[cfg(feature = "full")]
pub mod repository;
mod secret;
#[cfg(feature = "full")]
pub mod secret_store;
#[cfg(feature = "full")]
pub mod write;
/// Product name shared by the presentation adapters.

View File

@@ -521,7 +521,7 @@ impl<'a> MobileTotpService<'a> {
let period = uri.period().ok_or(OtpError::NotTotp)?;
retained.insert(path.clone());
entries.push(WatchSnapshotEntry::new(
path.clone(),
path.to_string(),
uri.issuer().map(str::to_owned),
uri.account().to_owned(),
uri.algorithm(),

View File

@@ -1,26 +1,30 @@
//! Versioned, replacement-only Apple Watch TOTP snapshots and sender state.
//! Versioned Apple Watch TOTP snapshots and the minimal offline Watch runtime.
use std::{
error::Error,
fmt, fs,
io::Write as _,
path::{Path, PathBuf},
};
use std::{error::Error, fmt, path::Path};
#[cfg(feature = "full")]
use cap_std::{ambient_authority, fs::Dir};
#[cfg(feature = "full")]
use cap_tempfile::TempFile;
use data_encoding::{HEXLOWER, HEXLOWER_PERMISSIVE};
use data_encoding::HEXLOWER;
#[cfg(feature = "full")]
use data_encoding::HEXLOWER_PERMISSIVE;
#[cfg(feature = "full")]
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
#[cfg(feature = "full")]
use std::{fs, io::Write as _, path::PathBuf};
use crate::{
otp::OtpAlgorithm,
repository::{EntryPath, SecretBytes},
otp_core::{self, OtpAlgorithm},
secret::SecretBytes,
};
const SNAPSHOT_MAGIC: &[u8; 4] = b"ISWS";
const RECEIPT_MAGIC: &[u8; 4] = b"ISWR";
const SNAPSHOT_VERSION: u16 = 1;
#[cfg(feature = "full")]
const JOURNAL_VERSION: u32 = 1;
const MAX_SNAPSHOT_BYTES: usize = 256 * 1024;
const MAX_ENTRIES: usize = 256;
@@ -57,7 +61,7 @@ impl MobileWatchSnapshotStatus {
}
pub struct WatchSnapshotEntry {
path: EntryPath,
path: String,
issuer: Option<String>,
account: String,
algorithm: OtpAlgorithm,
@@ -68,7 +72,7 @@ pub struct WatchSnapshotEntry {
impl WatchSnapshotEntry {
pub fn new(
path: EntryPath,
path: String,
issuer: Option<String>,
account: String,
algorithm: OtpAlgorithm,
@@ -87,7 +91,7 @@ impl WatchSnapshotEntry {
}
}
pub fn path(&self) -> &EntryPath {
pub fn path(&self) -> &str {
&self.path
}
pub fn issuer(&self) -> Option<&str> {
@@ -156,6 +160,7 @@ impl fmt::Debug for WatchSnapshot {
}
}
#[cfg(feature = "full")]
pub struct WatchSnapshotTransfer {
revision: u64,
selected_entries: u32,
@@ -163,6 +168,7 @@ pub struct WatchSnapshotTransfer {
delivered_receipt: Vec<u8>,
}
#[cfg(feature = "full")]
impl WatchSnapshotTransfer {
pub fn revision(&self) -> u64 {
self.revision
@@ -178,6 +184,7 @@ impl WatchSnapshotTransfer {
}
}
#[cfg(feature = "full")]
impl fmt::Debug for WatchSnapshotTransfer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -273,14 +280,184 @@ impl WatchSnapshotReceiver {
self.current = None;
self.accepted = None;
}
pub fn clear_secrets(&mut self) {
self.current = None;
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WatchPersistenceAction {
Keep,
Replace,
Delete,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WatchSnapshotUpdate {
apply: WatchSnapshotApply,
persistence: WatchPersistenceAction,
revision: Option<u64>,
selected_entries: u32,
receipt: Vec<u8>,
}
impl WatchSnapshotUpdate {
pub fn apply(&self) -> WatchSnapshotApply {
self.apply
}
pub fn persistence(&self) -> WatchPersistenceAction {
self.persistence
}
pub fn revision(&self) -> Option<u64> {
self.revision
}
pub fn selected_entries(&self) -> u32 {
self.selected_entries
}
pub fn receipt(&self) -> &[u8] {
&self.receipt
}
}
pub struct WatchTotpRecord {
path: String,
issuer: Option<String>,
account: String,
code: SecretBytes,
period: u64,
valid_until: u64,
}
impl WatchTotpRecord {
pub fn path(&self) -> &str {
&self.path
}
pub fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
pub fn account(&self) -> &str {
&self.account
}
pub fn code(&self) -> &SecretBytes {
&self.code
}
pub fn period(&self) -> u64 {
self.period
}
pub fn valid_until(&self) -> u64 {
self.valid_until
}
pub fn remaining_at(&self, unix_seconds: u64) -> u64 {
self.valid_until.saturating_sub(unix_seconds)
}
}
impl fmt::Debug for WatchTotpRecord {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("WatchTotpRecord")
.field("path", &self.path)
.field("issuer", &self.issuer)
.field("account", &self.account)
.field("code", &"[REDACTED]")
.field("period", &self.period)
.field("valid_until", &self.valid_until)
.finish()
}
}
#[derive(Default)]
pub struct WatchRuntime {
receiver: WatchSnapshotReceiver,
protected_data_available: bool,
}
impl WatchRuntime {
pub fn apply_snapshot(
&mut self,
bytes: Vec<u8>,
) -> Result<WatchSnapshotUpdate, WatchSnapshotError> {
let apply = self.receiver.apply(SecretBytes::new(bytes))?;
self.protected_data_available = true;
let persistence = match apply {
WatchSnapshotApply::Replaced | WatchSnapshotApply::PairingChanged => {
WatchPersistenceAction::Replace
}
WatchSnapshotApply::Revoked => WatchPersistenceAction::Delete,
WatchSnapshotApply::Duplicate | WatchSnapshotApply::Stale => {
WatchPersistenceAction::Keep
}
};
let current = self.receiver.current();
Ok(WatchSnapshotUpdate {
apply,
persistence,
revision: current.map(WatchSnapshot::revision),
selected_entries: current
.map(|snapshot| u32::try_from(snapshot.entries().len()).unwrap_or(u32::MAX))
.unwrap_or(0),
receipt: self.receiver.current_receipt().unwrap_or_default(),
})
}
pub fn records_at(
&self,
unix_seconds: u64,
) -> Result<Vec<WatchTotpRecord>, WatchSnapshotError> {
if !self.protected_data_available {
return Err(WatchSnapshotError::ProtectedDataUnavailable);
}
let Some(snapshot) = self.receiver.current() else {
return Ok(Vec::new());
};
snapshot
.entries()
.iter()
.map(|entry| {
let counter = unix_seconds / entry.period;
let valid_until = counter
.checked_add(1)
.and_then(|counter| counter.checked_mul(entry.period))
.ok_or(WatchSnapshotError::InvalidEntry)?;
let code = otp_core::code_for_counter(
entry.algorithm,
entry.secret.expose(),
entry.digits,
counter,
)
.map_err(|_| WatchSnapshotError::InvalidEntry)?;
Ok(WatchTotpRecord {
path: entry.path.clone(),
issuer: entry.issuer.clone(),
account: entry.account.clone(),
code,
period: entry.period,
valid_until,
})
})
.collect()
}
pub fn protected_data_unavailable(&mut self) {
self.receiver.clear_secrets();
self.protected_data_available = false;
}
pub fn no_persisted_snapshot(&mut self) {
self.receiver.revoke();
self.protected_data_available = true;
}
}
#[cfg(feature = "full")]
pub struct WatchSnapshotSender {
path: PathBuf,
journal: SenderJournal,
status: MobileWatchSnapshotStatus,
}
#[cfg(feature = "full")]
impl WatchSnapshotSender {
pub fn load(path: PathBuf) -> Self {
let journal = load_journal(&path).unwrap_or_default();
@@ -436,6 +613,7 @@ impl WatchSnapshotSender {
}
}
#[cfg(feature = "full")]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
struct SenderJournal {
@@ -455,6 +633,7 @@ struct SenderJournal {
current_revision: Option<u64>,
}
#[cfg(feature = "full")]
impl Default for SenderJournal {
fn default() -> Self {
Self {
@@ -469,6 +648,7 @@ impl Default for SenderJournal {
}
}
#[cfg(feature = "full")]
fn journal_version() -> u32 {
JOURNAL_VERSION
}
@@ -476,10 +656,12 @@ fn journal_version() -> u32 {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
enum ReceiptKind {
#[cfg(feature = "full")]
Delivered = 1,
Current = 2,
}
#[cfg(feature = "full")]
struct Receipt {
kind: ReceiptKind,
pairing: [u8; 32],
@@ -487,6 +669,7 @@ struct Receipt {
_digest: [u8; 32],
}
#[cfg(feature = "full")]
fn encode_entries(entries: &[WatchSnapshotEntry]) -> Result<Vec<u8>, WatchSnapshotError> {
if entries.len() > MAX_ENTRIES {
return Err(WatchSnapshotError::TooManyEntries);
@@ -518,6 +701,7 @@ fn encode_entries(entries: &[WatchSnapshotEntry]) -> Result<Vec<u8>, WatchSnapsh
Ok(output)
}
#[cfg(feature = "full")]
fn encode_snapshot(
pairing: [u8; 32],
revision: u64,
@@ -563,8 +747,10 @@ fn decode_snapshot(bytes: &[u8]) -> Result<WatchSnapshot, WatchSnapshotError> {
}
let mut entries = Vec::with_capacity(count);
for _ in 0..count {
let path = EntryPath::parse(&take_text(&mut input)?)
.map_err(|_| WatchSnapshotError::InvalidEntry)?;
let path = take_text(&mut input)?;
if !valid_entry_path(&path) {
return Err(WatchSnapshotError::InvalidEntry);
}
let issuer = match take_u8(&mut input)? {
0 => None,
1 => Some(take_text(&mut input)?),
@@ -631,6 +817,7 @@ fn encode_receipt(
output
}
#[cfg(feature = "full")]
fn decode_receipt(bytes: &[u8]) -> Result<Receipt, WatchSnapshotError> {
if bytes.len() != 4 + 2 + 1 + 32 + 8 + 32 + 32 {
return Err(WatchSnapshotError::InvalidReceipt);
@@ -663,6 +850,7 @@ fn decode_receipt(bytes: &[u8]) -> Result<Receipt, WatchSnapshotError> {
})
}
#[cfg(feature = "full")]
fn status_from_journal(journal: &SenderJournal) -> MobileWatchSnapshotStatus {
let (state, revision, detail) = if let Some(revision) = journal.current_revision {
(
@@ -699,6 +887,7 @@ fn status_from_journal(journal: &SenderJournal) -> MobileWatchSnapshotStatus {
}
}
#[cfg(feature = "full")]
fn snapshot_detail(state: &str, count: u32, revision: u64) -> String {
format!(
"Snapshot revision {revision} with {count} selected TOTP {} is {state}.",
@@ -706,6 +895,7 @@ fn snapshot_detail(state: &str, count: u32, revision: u64) -> String {
)
}
#[cfg(feature = "full")]
fn load_journal(path: &Path) -> Option<SenderJournal> {
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
@@ -715,6 +905,7 @@ fn load_journal(path: &Path) -> Option<SenderJournal> {
(journal.version == JOURNAL_VERSION).then_some(journal)
}
#[cfg(feature = "full")]
fn save_journal(path: &Path, journal: &SenderJournal) -> Result<(), WatchSnapshotError> {
let parent = path.parent().ok_or(WatchSnapshotError::JournalWrite)?;
fs::create_dir_all(parent).map_err(|_| WatchSnapshotError::JournalWrite)?;
@@ -737,7 +928,7 @@ fn save_journal(path: &Path, journal: &SenderJournal) -> Result<(), WatchSnapsho
.map_err(|_| WatchSnapshotError::JournalWrite)
}
#[cfg(unix)]
#[cfg(all(feature = "full", unix))]
fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), WatchSnapshotError> {
use cap_std::fs::{Permissions, PermissionsExt as _};
temporary
@@ -746,7 +937,7 @@ fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), WatchSnapshot
.map_err(|_| WatchSnapshotError::JournalWrite)
}
#[cfg(not(unix))]
#[cfg(all(feature = "full", not(unix)))]
fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), WatchSnapshotError> {
Ok(())
}
@@ -754,6 +945,7 @@ fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), WatchSnapsho
fn digest(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
#[cfg(feature = "full")]
fn decode_hash(text: &str) -> Result<[u8; 32], WatchSnapshotError> {
let bytes = HEXLOWER_PERMISSIVE
.decode(text.as_bytes())
@@ -762,15 +954,18 @@ fn decode_hash(text: &str) -> Result<[u8; 32], WatchSnapshotError> {
.try_into()
.map_err(|_| WatchSnapshotError::InvalidJournal)
}
#[cfg(feature = "full")]
fn put_u32(output: &mut Vec<u8>, value: u32) {
output.extend_from_slice(&value.to_be_bytes());
}
fn put_u64(output: &mut Vec<u8>, value: u64) {
output.extend_from_slice(&value.to_be_bytes());
}
#[cfg(feature = "full")]
fn put_text(output: &mut Vec<u8>, value: &str) -> Result<(), WatchSnapshotError> {
put_bytes(output, value.as_bytes(), MAX_TEXT_BYTES)
}
#[cfg(feature = "full")]
fn put_bytes(output: &mut Vec<u8>, value: &[u8], max: usize) -> Result<(), WatchSnapshotError> {
if value.len() > max {
return Err(WatchSnapshotError::InvalidEntry);
@@ -826,6 +1021,16 @@ fn take_text(input: &mut &[u8]) -> Result<String, WatchSnapshotError> {
.map_err(|_| WatchSnapshotError::InvalidEntry)
}
fn valid_entry_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
&& !value.ends_with('/')
&& !path.is_absolute()
&& path
.components()
.all(|component| matches!(component, std::path::Component::Normal(_)))
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WatchSnapshotError {
InvalidPairing,
@@ -841,6 +1046,7 @@ pub enum WatchSnapshotError {
NoPendingSnapshot,
InvalidJournal,
JournalWrite,
ProtectedDataUnavailable,
}
impl fmt::Display for WatchSnapshotError {
@@ -861,6 +1067,7 @@ impl fmt::Display for WatchSnapshotError {
Self::NoPendingSnapshot => "there is no pending Apple Watch snapshot",
Self::InvalidJournal => "the Apple Watch synchronization journal is invalid",
Self::JournalWrite => "the Apple Watch synchronization journal could not be saved",
Self::ProtectedDataUnavailable => "protected Apple Watch data is unavailable",
})
}
}

View File

@@ -3,15 +3,13 @@
use std::{error::Error, fmt, ops::Range, str, sync::Mutex};
use data_encoding::BASE32_NOPAD;
use hmac::Hmac;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use zeroize::Zeroize as _;
use crate::{
command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest},
crypto::{CryptoError, KeyStore, SecretProvider},
git::{AutomaticEntryCommitter, GitError, GitIdentity},
otp_core,
recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy},
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
write::{EntryAction, EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision},
@@ -27,12 +25,7 @@ pub enum OtpKind {
Hotp,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpAlgorithm {
Sha1,
Sha256,
Sha512,
}
pub use crate::otp_core::OtpAlgorithm;
/// A validated key URI whose encoded and decoded secrets zeroize on drop.
pub struct OtpUri {
@@ -251,21 +244,8 @@ impl OtpUri {
}
pub fn code_for_counter(&self, counter: u64) -> Result<SecretBytes, OtpError> {
let message = counter.to_be_bytes();
let mut digest = match self.algorithm {
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(self.secret.expose(), &message)?,
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(self.secret.expose(), &message)?,
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(self.secret.expose(), &message)?,
};
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
let binary = (u32::from(digest[offset]) & 0x7f) << 24
| u32::from(digest[offset + 1]) << 16
| u32::from(digest[offset + 2]) << 8
| u32::from(digest[offset + 3]);
digest.zeroize();
let modulus = 10_u32.pow(self.digits);
let code = format!("{:0width$}", binary % modulus, width = self.digits as usize);
Ok(SecretBytes::new(code.into_bytes()))
otp_core::code_for_counter(self.algorithm, self.secret.expose(), self.digits, counter)
.map_err(|_| OtpError::InvalidSecret)
}
fn incremented_hotp(&self) -> Result<(u64, Self), OtpError> {
@@ -1204,19 +1184,6 @@ fn percent_encode(value: &str) -> String {
encoded
}
fn hmac_digest<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpError>
where
M: hmac::digest::Mac + hmac::digest::KeyInit,
{
let mut mac =
<M as hmac::digest::Mac>::new_from_slice(key).map_err(|_| OtpError::InvalidSecret)?;
mac.update(message);
let mut output = mac.finalize().into_bytes();
let digest = output.to_vec();
output.fill(0);
Ok(digest)
}
pub(crate) fn find_uri(
plaintext: &SecretBytes,
entry: &EntryPath,

View File

@@ -0,0 +1,97 @@
use std::{error::Error, fmt};
use hmac::Hmac;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use zeroize::Zeroize as _;
use crate::secret::SecretBytes;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpAlgorithm {
Sha1,
Sha256,
Sha512,
}
pub fn code_for_counter(
algorithm: OtpAlgorithm,
secret: &[u8],
digits: u32,
counter: u64,
) -> Result<SecretBytes, OtpCoreError> {
if secret.is_empty() || !matches!(digits, 6 | 8) {
return Err(OtpCoreError);
}
let message = counter.to_be_bytes();
let mut digest = match algorithm {
OtpAlgorithm::Sha1 => hmac_digest::<Hmac<Sha1>>(secret, &message)?,
OtpAlgorithm::Sha256 => hmac_digest::<Hmac<Sha256>>(secret, &message)?,
OtpAlgorithm::Sha512 => hmac_digest::<Hmac<Sha512>>(secret, &message)?,
};
let offset = usize::from(digest[digest.len() - 1] & 0x0f);
let binary = (u32::from(digest[offset]) & 0x7f) << 24
| u32::from(digest[offset + 1]) << 16
| u32::from(digest[offset + 2]) << 8
| u32::from(digest[offset + 3]);
digest.zeroize();
let code = format!(
"{:0width$}",
binary % 10_u32.pow(digits),
width = digits as usize
);
Ok(SecretBytes::new(code.into_bytes()))
}
fn hmac_digest<M>(key: &[u8], message: &[u8]) -> Result<Vec<u8>, OtpCoreError>
where
M: hmac::digest::Mac + hmac::digest::KeyInit,
{
let mut mac = <M as hmac::digest::Mac>::new_from_slice(key).map_err(|_| OtpCoreError)?;
mac.update(message);
let mut output = mac.finalize().into_bytes();
let digest = output.to_vec();
output.fill(0);
Ok(digest)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OtpCoreError;
impl fmt::Display for OtpCoreError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("OTP parameters are invalid")
}
}
impl Error for OtpCoreError {}
#[cfg(test)]
mod tests {
use super::{OtpAlgorithm, code_for_counter};
#[test]
fn rfc_6238_vectors_cover_every_watch_algorithm() {
let vectors = [
(
OtpAlgorithm::Sha1,
b"12345678901234567890".as_slice(),
"94287082",
),
(
OtpAlgorithm::Sha256,
b"12345678901234567890123456789012".as_slice(),
"46119246",
),
(
OtpAlgorithm::Sha512,
b"1234567890123456789012345678901234567890123456789012345678901234".as_slice(),
"90693936",
),
];
for (algorithm, secret, expected) in vectors {
let code = code_for_counter(algorithm, secret, 8, 59 / 30).expect("RFC vector");
assert_eq!(code.expose(), expected.as_bytes());
}
}
}

View File

@@ -12,9 +12,9 @@ use std::{
#[cfg(test)]
use std::collections::BTreeSet;
pub use crate::secret::SecretBytes;
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use zeroize::Zeroize;
const ENTRY_EXTENSION: &str = "gpg";
const RECIPIENT_FILE: &str = ".gpg-id";
@@ -132,35 +132,6 @@ impl fmt::Debug for EncryptedEntry {
}
}
/// Decrypted bytes that are redacted in diagnostics and zeroed when dropped.
pub struct SecretBytes(Vec<u8>);
impl SecretBytes {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn expose(&self) -> &[u8] {
&self.0
}
pub fn expose_mut(&mut self) -> &mut [u8] {
&mut self.0
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretBytes([REDACTED])")
}
}
impl Drop for SecretBytes {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EntryRecord {
path: EntryPath,

View File

@@ -0,0 +1,32 @@
use std::fmt;
use zeroize::Zeroize as _;
/// Secret bytes that are redacted in diagnostics and zeroed when dropped.
pub struct SecretBytes(Vec<u8>);
impl SecretBytes {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn expose(&self) -> &[u8] {
&self.0
}
pub fn expose_mut(&mut self) -> &mut [u8] {
&mut self.0
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SecretBytes([REDACTED])")
}
}
impl Drop for SecretBytes {
fn drop(&mut self) {
self.0.zeroize();
}
}