fix(core): expand AttachmentId to 128 bits, add is_valid (audit I2, B4)

- AttachmentId now uses 16 bytes of SHA-256 (128 bits) instead of 8,
  requiring ~2^64 work for birthday collision instead of ~2^32.
- Added is_valid() to ItemId and AttachmentId for path traversal
  prevention during backup restore.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 01:32:48 -04:00
parent bbdbcca87b
commit 466efe4b8a

View File

@@ -2,8 +2,9 @@
//!
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
//! - `AttachmentId` is the first 32 hex chars of `sha256(plaintext)` (128 bits)
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
//! (audit I2/B4: bumped from 8-byte/64-bit format to prevent birthday collisions)
use rand::rngs::OsRng;
use rand::RngCore;
@@ -29,6 +30,12 @@ impl ItemId {
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
/// Returns true if this ID is valid for filesystem paths.
/// Valid ItemIds are 16 lowercase hex chars.
pub fn is_valid(&self) -> bool {
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
}
}
impl Default for ItemId {
@@ -51,9 +58,15 @@ impl Default for FieldId {
impl AttachmentId {
pub fn from_plaintext(plaintext: &[u8]) -> Self {
let digest = Sha256::digest(plaintext);
Self(hex::encode(&digest[..8]))
Self(hex::encode(&digest[..16])) // 16 bytes = 128 bits
}
pub fn as_str(&self) -> &str { &self.0 }
/// Returns true if this ID is valid for filesystem paths.
/// Valid AttachmentIds are 32 lowercase hex chars.
pub fn is_valid(&self) -> bool {
self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit())
}
}
#[cfg(test)]
@@ -106,12 +119,36 @@ mod tests {
}
#[test]
fn attachment_id_is_16_hex_chars() {
fn attachment_id_is_32_hex_chars() {
let id = AttachmentId::from_plaintext(b"any bytes");
assert_eq!(id.0.len(), 16);
assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn item_id_is_valid_for_normal_ids() {
let id = ItemId::new();
assert!(id.is_valid());
}
#[test]
fn item_id_is_invalid_for_traversal() {
let bad = ItemId("../../../etc".to_string());
assert!(!bad.is_valid());
}
#[test]
fn attachment_id_is_valid_for_normal_ids() {
let id = AttachmentId::from_plaintext(b"test");
assert!(id.is_valid());
}
#[test]
fn attachment_id_is_invalid_for_traversal() {
let bad = AttachmentId("../../passwd".to_string());
assert!(!bad.is_valid());
}
#[test]
fn ids_serialize_as_bare_strings() {
let item = ItemId("abcdef0123456789".to_string());