fix: security hardening — 40 findings from full codebase review
Full codebase review by 4 independent agents (security, architecture,
code quality, correctness) identified ~80 findings. This commit fixes 40
of them across all workspace crates.
Critical fixes:
- Federation service: validate origin against mTLS cert CN/SAN (C1)
- WS bridge: add DM channel auth, size limits, rate limiting (C2)
- hpke_seal: panic on error instead of silent empty ciphertext (C3)
- hpke_setup_sender_and_export: error on parse fail, no PQ downgrade (C7)
Security fixes:
- Zeroize: seed_bytes() returns Zeroizing<[u8;32]>, private_to_bytes()
returns Zeroizing<Vec<u8>>, ClientAuth.access_token, SessionState.password,
conversation hex_key all wrapped in Zeroizing
- Keystore: 0o600 file permissions on Unix
- MeshIdentity: 0o600 file permissions on Unix
- Timing floors: resolveIdentity + WS bridge resolve_user get 5ms floor
- Mobile: TLS verification gated behind insecure-dev feature flag
- Proto: from_bytes default limit tightened from 64 MiB to 8 MiB
Correctness fixes:
- fetch_wait: register waiter before fetch to close TOCTOU window
- MeshEnvelope: exclude hop_count from signature (forwarding no longer
invalidates sender signature)
- BroadcastChannel: encrypt returns Result instead of panicking
- transcript: rename verify_transcript_chain → validate_transcript_structure
- group.rs: extract shared process_incoming() for receive_message variants
- auth_ops: remove spurious RegistrationRequest deserialization
- MeshStore.seen: bounded to 100K with FIFO eviction
Quality fixes:
- FFI error classification: typed downcast instead of string matching
- Plugin HookVTable: SAFETY documentation for unsafe Send+Sync
- clippy::unwrap_used: warn → deny workspace-wide
- Various .unwrap_or("") → proper error returns
Review report: docs/REVIEW-2026-03-04.md
152 tests passing (72 core + 35 server + 14 E2E + 1 doctest + 30 P2P)
This commit is contained in:
@@ -10,12 +10,20 @@ pub enum CoreError {
|
||||
#[error("Cap'n Proto error: {0}")]
|
||||
Capnp(#[from] capnp::Error),
|
||||
|
||||
/// An MLS operation failed.
|
||||
/// An MLS operation failed (string description).
|
||||
///
|
||||
/// The inner string is the debug representation of the openmls error.
|
||||
/// Preserved for backward compatibility. Prefer [`CoreError::MlsError`]
|
||||
/// for new code that wraps typed openmls errors.
|
||||
#[error("MLS error: {0}")]
|
||||
Mls(String),
|
||||
|
||||
/// An MLS operation failed (typed, boxed error).
|
||||
///
|
||||
/// Wraps the underlying openmls error so callers can downcast to specific
|
||||
/// error types when needed.
|
||||
#[error("MLS error: {0}")]
|
||||
MlsError(Box<dyn std::error::Error + Send + Sync>),
|
||||
|
||||
/// A hybrid KEM (X25519 + ML-KEM-768) operation failed.
|
||||
#[error("hybrid KEM error: {0}")]
|
||||
HybridKem(#[from] crate::hybrid_kem::HybridKemError),
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use openmls::prelude::{
|
||||
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, GroupId, KeyPackage,
|
||||
KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, MlsMessageOut,
|
||||
@@ -468,7 +470,36 @@ impl GroupMember {
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if the message is malformed, fails
|
||||
/// authentication, or the group state is inconsistent.
|
||||
pub fn receive_message(&mut self, mut bytes: &[u8]) -> Result<ReceivedMessage, CoreError> {
|
||||
pub fn receive_message(&mut self, bytes: &[u8]) -> Result<ReceivedMessage, CoreError> {
|
||||
let (sender, content) = self.process_incoming(bytes)?;
|
||||
let _ = sender; // not needed for this variant
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Process an incoming TLS-encoded MLS message and return sender identity + plaintext for application messages.
|
||||
///
|
||||
/// Same as [`receive_message`], but for Application messages returns
|
||||
/// `(sender_identity_bytes, plaintext)` so the client can display who sent the message.
|
||||
pub fn receive_message_with_sender(
|
||||
&mut self,
|
||||
bytes: &[u8],
|
||||
) -> Result<ReceivedMessageWithSender, CoreError> {
|
||||
let (sender_identity, content) = self.process_incoming(bytes)?;
|
||||
Ok(match content {
|
||||
ReceivedMessage::Application(plaintext) => {
|
||||
ReceivedMessageWithSender::Application(sender_identity, plaintext)
|
||||
}
|
||||
ReceivedMessage::StateChanged => ReceivedMessageWithSender::StateChanged,
|
||||
ReceivedMessage::SelfRemoved => ReceivedMessageWithSender::SelfRemoved,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared MLS message processing: deserialize, authenticate, and apply
|
||||
/// the incoming message. Returns `(sender_identity_bytes, result)`.
|
||||
fn process_incoming(
|
||||
&mut self,
|
||||
mut bytes: &[u8],
|
||||
) -> Result<(Vec<u8>, ReceivedMessage), CoreError> {
|
||||
let group = self
|
||||
.group
|
||||
.as_mut()
|
||||
@@ -488,9 +519,11 @@ impl GroupMember {
|
||||
.process_message(&self.backend, protocol_message)
|
||||
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
|
||||
|
||||
let sender_identity = processed.credential().identity().to_vec();
|
||||
|
||||
match processed.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(app) => {
|
||||
Ok(ReceivedMessage::Application(app.into_bytes()))
|
||||
Ok((sender_identity, ReceivedMessage::Application(app.into_bytes())))
|
||||
}
|
||||
ProcessedMessageContent::StagedCommitMessage(staged) => {
|
||||
// Check if this commit removes us.
|
||||
@@ -505,79 +538,19 @@ impl GroupMember {
|
||||
|
||||
if self_removed {
|
||||
self.group = None;
|
||||
Ok(ReceivedMessage::SelfRemoved)
|
||||
Ok((sender_identity, ReceivedMessage::SelfRemoved))
|
||||
} else {
|
||||
Ok(ReceivedMessage::StateChanged)
|
||||
Ok((sender_identity, ReceivedMessage::StateChanged))
|
||||
}
|
||||
}
|
||||
// Proposals are stored for a later Commit; nothing to return yet.
|
||||
ProcessedMessageContent::ProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
Ok(ReceivedMessage::StateChanged)
|
||||
Ok((sender_identity, ReceivedMessage::StateChanged))
|
||||
}
|
||||
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
Ok(ReceivedMessage::StateChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process an incoming TLS-encoded MLS message and return sender identity + plaintext for application messages.
|
||||
///
|
||||
/// Same as [`receive_message`], but for Application messages returns
|
||||
/// `(sender_identity_bytes, plaintext)` so the client can display who sent the message.
|
||||
pub fn receive_message_with_sender(
|
||||
&mut self,
|
||||
mut bytes: &[u8],
|
||||
) -> Result<ReceivedMessageWithSender, CoreError> {
|
||||
let group = self
|
||||
.group
|
||||
.as_mut()
|
||||
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
|
||||
|
||||
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes)
|
||||
.map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?;
|
||||
|
||||
let protocol_message = match msg_in.extract() {
|
||||
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
|
||||
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
|
||||
_ => return Err(CoreError::Mls("not a protocol message".into())),
|
||||
};
|
||||
|
||||
let processed = group
|
||||
.process_message(&self.backend, protocol_message)
|
||||
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
|
||||
|
||||
let sender_identity = processed.credential().identity().to_vec();
|
||||
|
||||
match processed.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(app) => {
|
||||
Ok(ReceivedMessageWithSender::Application(sender_identity, app.into_bytes()))
|
||||
}
|
||||
ProcessedMessageContent::StagedCommitMessage(staged) => {
|
||||
let own_index = group.own_leaf_index();
|
||||
let self_removed = staged.remove_proposals().any(|queued| {
|
||||
queued.remove_proposal().removed() == own_index
|
||||
});
|
||||
|
||||
group
|
||||
.merge_staged_commit(&self.backend, *staged)
|
||||
.map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?;
|
||||
|
||||
if self_removed {
|
||||
self.group = None;
|
||||
Ok(ReceivedMessageWithSender::SelfRemoved)
|
||||
} else {
|
||||
Ok(ReceivedMessageWithSender::StateChanged)
|
||||
}
|
||||
}
|
||||
ProcessedMessageContent::ProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
Ok(ReceivedMessageWithSender::StateChanged)
|
||||
}
|
||||
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
Ok(ReceivedMessageWithSender::StateChanged)
|
||||
Ok((sender_identity, ReceivedMessage::StateChanged))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -597,7 +570,10 @@ impl GroupMember {
|
||||
}
|
||||
|
||||
/// Return the private seed of the identity (for persistence).
|
||||
pub fn identity_seed(&self) -> [u8; 32] {
|
||||
///
|
||||
/// The returned value is wrapped in `Zeroizing` so it is securely erased
|
||||
/// when dropped.
|
||||
pub fn identity_seed(&self) -> Zeroizing<[u8; 32]> {
|
||||
self.identity.seed_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -191,32 +191,22 @@ impl OpenMlsCrypto for HybridCrypto {
|
||||
ptxt: &[u8],
|
||||
) -> HpkeCiphertext {
|
||||
if Self::is_hybrid_public_key(pk_r) {
|
||||
let recipient_pk = match HybridPublicKey::from_bytes(pk_r) {
|
||||
Ok(pk) => pk,
|
||||
// Key parsed as hybrid length but failed to deserialize — this is
|
||||
// a real error, not a reason to silently fall back to classical HPKE.
|
||||
Err(_) => return HpkeCiphertext {
|
||||
kem_output: Vec::new().into(),
|
||||
ciphertext: Vec::new().into(),
|
||||
},
|
||||
};
|
||||
// The trait `OpenMlsCrypto::hpke_seal` returns `HpkeCiphertext` (not
|
||||
// `Result`), so we cannot propagate errors through the return type.
|
||||
// Returning an empty ciphertext would silently cause data loss.
|
||||
// Instead, panic on failure — a hybrid key that passes the length
|
||||
// check but fails deserialization or encryption indicates a critical
|
||||
// bug (corrupted key material), not a recoverable condition.
|
||||
let recipient_pk = HybridPublicKey::from_bytes(pk_r)
|
||||
.expect("hybrid public key deserialization failed — key material is corrupted");
|
||||
// Pass HPKE info and aad through for proper context binding (RFC 9180).
|
||||
match hybrid_encrypt(&recipient_pk, ptxt, info, aad) {
|
||||
Ok(envelope) => {
|
||||
let kem_output = envelope[..HYBRID_KEM_OUTPUT_LEN].to_vec();
|
||||
let ciphertext = envelope[HYBRID_KEM_OUTPUT_LEN..].to_vec();
|
||||
HpkeCiphertext {
|
||||
kem_output: kem_output.into(),
|
||||
ciphertext: ciphertext.into(),
|
||||
}
|
||||
}
|
||||
// Encryption failed with a hybrid key — return empty ciphertext
|
||||
// rather than silently falling back to classical HPKE with an
|
||||
// incompatible key.
|
||||
Err(_) => HpkeCiphertext {
|
||||
kem_output: Vec::new().into(),
|
||||
ciphertext: Vec::new().into(),
|
||||
},
|
||||
let envelope = hybrid_encrypt(&recipient_pk, ptxt, info, aad)
|
||||
.expect("hybrid HPKE encryption failed — critical crypto error");
|
||||
let kem_output = envelope[..HYBRID_KEM_OUTPUT_LEN].to_vec();
|
||||
let ciphertext = envelope[HYBRID_KEM_OUTPUT_LEN..].to_vec();
|
||||
HpkeCiphertext {
|
||||
kem_output: kem_output.into(),
|
||||
ciphertext: ciphertext.into(),
|
||||
}
|
||||
} else {
|
||||
self.rust_crypto.hpke_seal(config, pk_r, info, aad, ptxt)
|
||||
@@ -257,14 +247,11 @@ impl OpenMlsCrypto for HybridCrypto {
|
||||
exporter_length: usize,
|
||||
) -> Result<(Vec<u8>, ExporterSecret), CryptoError> {
|
||||
if Self::is_hybrid_public_key(pk_r) {
|
||||
let recipient_pk = match HybridPublicKey::from_bytes(pk_r) {
|
||||
Ok(pk) => pk,
|
||||
Err(_) => {
|
||||
return self.rust_crypto.hpke_setup_sender_and_export(
|
||||
config, pk_r, info, exporter_context, exporter_length,
|
||||
)
|
||||
}
|
||||
};
|
||||
// A key that passes the hybrid length check but fails deserialization
|
||||
// is corrupted — return an error instead of silently downgrading to
|
||||
// classical crypto (which would defeat PQ protection).
|
||||
let recipient_pk = HybridPublicKey::from_bytes(pk_r)
|
||||
.map_err(|_| CryptoError::SenderSetupError)?;
|
||||
let (kem_output, shared_secret) =
|
||||
hybrid_encapsulate_only(&recipient_pk).map_err(|_| CryptoError::SenderSetupError)?;
|
||||
let exported = hybrid_export(&shared_secret, exporter_context, exporter_length);
|
||||
@@ -302,8 +289,9 @@ impl OpenMlsCrypto for HybridCrypto {
|
||||
fn derive_hpke_keypair(&self, config: HpkeConfig, ikm: &[u8]) -> HpkeKeyPair {
|
||||
if self.hybrid_enabled && config.0 == HpkeKemType::DhKem25519 {
|
||||
let kp = HybridKeypair::derive_from_ikm(ikm);
|
||||
let private_bytes = kp.private_to_bytes();
|
||||
HpkeKeyPair {
|
||||
private: kp.private_to_bytes().into(),
|
||||
private: private_bytes.as_slice().into(),
|
||||
public: kp.public_key().to_bytes(),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -159,11 +159,14 @@ impl HybridKeypair {
|
||||
}
|
||||
|
||||
/// Serialise private key for MLS key store: x25519_sk(32) || mlkem_dk(2400).
|
||||
pub fn private_to_bytes(&self) -> Vec<u8> {
|
||||
///
|
||||
/// The returned value is wrapped in [`Zeroizing`] so secret key material
|
||||
/// is securely erased when dropped.
|
||||
pub fn private_to_bytes(&self) -> Zeroizing<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(HYBRID_PRIVATE_KEY_LEN);
|
||||
out.extend_from_slice(self.x25519_sk.as_bytes());
|
||||
out.extend_from_slice(self.mlkem_dk.as_bytes().as_slice());
|
||||
out
|
||||
Zeroizing::new(out)
|
||||
}
|
||||
|
||||
/// Reconstruct a hybrid keypair from private key bytes (from MLS key store).
|
||||
|
||||
@@ -47,8 +47,11 @@ impl IdentityKeypair {
|
||||
}
|
||||
|
||||
/// Return the raw 32-byte private seed (for persistence).
|
||||
pub fn seed_bytes(&self) -> [u8; 32] {
|
||||
*self.seed
|
||||
///
|
||||
/// The returned value is wrapped in [`Zeroizing`] so it is securely
|
||||
/// erased when dropped, preventing the seed from lingering in memory.
|
||||
pub fn seed_bytes(&self) -> Zeroizing<[u8; 32]> {
|
||||
Zeroizing::new(*self.seed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,18 @@ use openmls_traits::key_store::{MlsEntity, OpenMlsKeyStore};
|
||||
///
|
||||
/// In-memory when `path` is `None`; otherwise flushes the entire map to disk on
|
||||
/// every store/delete so HPKE init keys survive process restarts.
|
||||
///
|
||||
/// # Serialization
|
||||
///
|
||||
/// Uses bincode for both individual MLS entity values and the outer HashMap
|
||||
/// container. This is required because OpenMLS types use bincode-compatible
|
||||
/// serialization, and `HashMap<Vec<u8>, Vec<u8>>` requires a binary format
|
||||
/// (JSON mandates string keys).
|
||||
///
|
||||
/// # Persistence security
|
||||
///
|
||||
/// When `path` is set, file permissions are restricted to owner-only (0o600)
|
||||
/// on Unix platforms, since the store may contain HPKE private keys.
|
||||
#[derive(Debug)]
|
||||
pub struct DiskKeyStore {
|
||||
path: Option<PathBuf>,
|
||||
@@ -42,16 +54,22 @@ impl DiskKeyStore {
|
||||
if bytes.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
bincode::deserialize(&bytes).map_err(|_| DiskKeyStoreError::Serialization)?
|
||||
bincode::deserialize(&bytes)
|
||||
.map_err(|_| DiskKeyStoreError::Serialization)?
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
let store = Self {
|
||||
path: Some(path),
|
||||
values: RwLock::new(values),
|
||||
})
|
||||
};
|
||||
|
||||
// Set restrictive file permissions on the keystore file.
|
||||
store.set_file_permissions()?;
|
||||
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn flush(&self) -> Result<(), DiskKeyStoreError> {
|
||||
@@ -63,7 +81,28 @@ impl DiskKeyStore {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
fs::write(path, bytes).map_err(|e| DiskKeyStoreError::Io(e.to_string()))
|
||||
fs::write(path, &bytes).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?;
|
||||
self.set_file_permissions()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restrict file permissions to owner-only (0o600) on Unix.
|
||||
#[cfg(unix)]
|
||||
fn set_file_permissions(&self) -> Result<(), DiskKeyStoreError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(path) = &self.path {
|
||||
if path.exists() {
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
fs::set_permissions(path, perms)
|
||||
.map_err(|e| DiskKeyStoreError::Io(format!("set permissions: {e}")))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_file_permissions(&self) -> Result<(), DiskKeyStoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +116,7 @@ impl OpenMlsKeyStore for DiskKeyStore {
|
||||
type Error = DiskKeyStoreError;
|
||||
|
||||
fn store<V: MlsEntity>(&self, k: &[u8], v: &V) -> Result<(), Self::Error> {
|
||||
let value = serde_json::to_vec(v).map_err(|_| DiskKeyStoreError::Serialization)?;
|
||||
let value = bincode::serialize(v).map_err(|_| DiskKeyStoreError::Serialization)?;
|
||||
let mut values = self.values.write().map_err(|_| DiskKeyStoreError::Io("lock poisoned".into()))?;
|
||||
values.insert(k.to_vec(), value);
|
||||
drop(values);
|
||||
@@ -91,7 +130,7 @@ impl OpenMlsKeyStore for DiskKeyStore {
|
||||
};
|
||||
values
|
||||
.get(k)
|
||||
.and_then(|bytes| serde_json::from_slice(bytes).ok())
|
||||
.and_then(|bytes| bincode::deserialize(bytes).ok())
|
||||
}
|
||||
|
||||
fn delete<V: MlsEntity>(&self, k: &[u8]) -> Result<(), Self::Error> {
|
||||
@@ -101,4 +140,3 @@ impl OpenMlsKeyStore for DiskKeyStore {
|
||||
self.flush()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,9 +72,12 @@ pub use hybrid_kem::{
|
||||
pub use identity::{verify_delivery_proof, IdentityKeypair};
|
||||
pub use safety_numbers::compute_safety_number;
|
||||
pub use transcript::{
|
||||
read_transcript, verify_transcript_chain, ChainVerdict, DecodedRecord, TranscriptRecord,
|
||||
read_transcript, validate_transcript_structure, ChainVerdict, DecodedRecord, TranscriptRecord,
|
||||
TranscriptWriter,
|
||||
};
|
||||
// Deprecated re-export for backward compatibility.
|
||||
#[allow(deprecated)]
|
||||
pub use transcript::verify_transcript_chain;
|
||||
|
||||
// ── Public API (native only) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -208,11 +208,17 @@ pub fn read_transcript(
|
||||
Ok((records, verdict))
|
||||
}
|
||||
|
||||
/// Verify the hash chain without decrypting record contents.
|
||||
/// Validate the structural integrity of a transcript file without decrypting.
|
||||
///
|
||||
/// Checks that the file header is valid and that all length-prefixed
|
||||
/// ciphertext records can be parsed. Does **not** verify the inner
|
||||
/// `prev_hash` chain (which requires the decryption password) — only
|
||||
/// confirms that the file is well-formed and no records have been
|
||||
/// truncated or removed.
|
||||
///
|
||||
/// Returns `Ok(ChainVerdict)` if the file header is valid; parsing errors
|
||||
/// return `Err`. The chain verdict indicates whether all hashes matched.
|
||||
pub fn verify_transcript_chain(data: &[u8]) -> Result<ChainVerdict, CoreError> {
|
||||
/// return `Err`.
|
||||
pub fn validate_transcript_structure(data: &[u8]) -> Result<ChainVerdict, CoreError> {
|
||||
let (_, mut rest) = parse_header(data)?;
|
||||
|
||||
let mut expected_prev: [u8; 32] = [0u8; 32];
|
||||
@@ -250,6 +256,12 @@ pub fn verify_transcript_chain(data: &[u8]) -> Result<ChainVerdict, CoreError> {
|
||||
Ok(ChainVerdict::Ok { records: count })
|
||||
}
|
||||
|
||||
/// Deprecated alias for [`validate_transcript_structure`].
|
||||
#[deprecated(note = "renamed to validate_transcript_structure — this function only checks structure, not hashes")]
|
||||
pub fn verify_transcript_chain(data: &[u8]) -> Result<ChainVerdict, CoreError> {
|
||||
validate_transcript_structure(data)
|
||||
}
|
||||
|
||||
/// Result of hash-chain verification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ChainVerdict {
|
||||
@@ -515,7 +527,7 @@ mod tests {
|
||||
.expect("write");
|
||||
}
|
||||
|
||||
let verdict = verify_transcript_chain(&buf).expect("verify");
|
||||
let verdict = validate_transcript_structure(&buf).expect("verify");
|
||||
assert_eq!(verdict, ChainVerdict::Ok { records: 5 });
|
||||
}
|
||||
|
||||
@@ -537,7 +549,7 @@ mod tests {
|
||||
|
||||
// Truncate the last few bytes — should fail parsing.
|
||||
let truncated = &buf[..buf.len() - 5];
|
||||
let result = verify_transcript_chain(truncated);
|
||||
let result = validate_transcript_structure(truncated);
|
||||
assert!(result.is_err(), "truncated file must be detected");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user