An encrypted personal cloud built on the AT Protocol.

Harden file permissions and redact secrets from Debug output

Security hardening across all three crates:

File permissions: config, identity, session, and keyring files are now
written with 0600 permissions via write_sensitive_file(), directories
with 0700 via ensure_sensitive_dir(). Loading identity.json checks
permissions and bails with a chmod hint if group/world-readable.

Debug redaction: new opake-derive crate provides #[derive(RedactedDebug)]
with #[redact] field attributes, generating Debug impls that show byte
length instead of content. Applied to ContentKey, EncryptedPayload,
Session (JWTs), and Identity (private keys).

sans-self.org 5b144b22 77f811b3

Waiting for spindle ...
+506 -37
+3
CHANGELOG.md
··· 7 7 ## [Unreleased] 8 8 9 9 ### Security 10 + - Fix ContentKey Debug impl to redact secret bytes (#86) 11 + - Add file permission hardening for sensitive config and key files (#127) 10 12 - Remove bearer token authentication fallback from AppView (#109) 11 13 12 14 ### Added 15 + - Add inbox CLI command for discovering shared grants via appview (#128) 13 16 - Add inbox CLI command for discovering shared grants via appview (#128) 14 17 - Audit workspace dependencies for consolidation and upgrades (#110) 15 18 - Add AppView production readiness: clap, DID auth, XDG, health, docs (#101)
+10
Cargo.lock
··· 1390 1390 "getrandom 0.2.17", 1391 1391 "hkdf", 1392 1392 "log", 1393 + "opake-derive", 1393 1394 "serde", 1394 1395 "serde_json", 1395 1396 "sha2", 1396 1397 "thiserror 2.0.18", 1397 1398 "tokio", 1398 1399 "x25519-dalek", 1400 + ] 1401 + 1402 + [[package]] 1403 + name = "opake-derive" 1404 + version = "0.1.0" 1405 + dependencies = [ 1406 + "proc-macro2", 1407 + "quote", 1408 + "syn", 1399 1409 ] 1400 1410 1401 1411 [[package]]
+1 -1
Cargo.toml
··· 1 1 [workspace] 2 - members = ["crates/opake-core", "crates/opake-cli", "crates/opake-appview"] 2 + members = ["crates/opake-core", "crates/opake-cli", "crates/opake-appview", "crates/opake-derive"] 3 3 resolver = "2" 4 4 5 5 [workspace.package]
+27 -17
crates/opake-cli/src/config.rs
··· 1 1 use std::collections::BTreeMap; 2 2 use std::fs; 3 - use std::path::PathBuf; 3 + use std::os::unix::fs::PermissionsExt; 4 + use std::path::{Path, PathBuf}; 4 5 use std::sync::RwLock; 5 6 6 7 use anyhow::Context; 7 8 use serde::de::DeserializeOwned; 8 9 use serde::{Deserialize, Serialize}; 10 + 11 + const SENSITIVE_FILE_MODE: u32 = 0o600; 12 + const SENSITIVE_DIR_MODE: u32 = 0o700; 9 13 10 14 static DATA_DIR: RwLock<Option<PathBuf>> = RwLock::new(None); 11 15 ··· 31 35 pub struct AccountConfig { 32 36 pub pds_url: String, 33 37 pub handle: String, 38 + } 39 + 40 + /// Write a file and set its permissions to 0600 (owner read/write only). 41 + pub fn write_sensitive_file(path: &Path, content: impl AsRef<[u8]>) -> anyhow::Result<()> { 42 + fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; 43 + fs::set_permissions(path, fs::Permissions::from_mode(SENSITIVE_FILE_MODE)) 44 + .with_context(|| format!("failed to set permissions on {}", path.display())) 45 + } 46 + 47 + /// Create a directory (and parents) with 0700 permissions. 48 + /// Always sets permissions, even on existing dirs, to fix upgrades. 49 + pub fn ensure_sensitive_dir(path: &Path) -> anyhow::Result<()> { 50 + fs::create_dir_all(path) 51 + .with_context(|| format!("failed to create directory: {}", path.display()))?; 52 + fs::set_permissions(path, fs::Permissions::from_mode(SENSITIVE_DIR_MODE)) 53 + .with_context(|| format!("failed to set permissions on {}", path.display())) 34 54 } 35 55 36 56 /// The resolved data directory. Must call `init_data_dir()` before use. ··· 42 62 .expect("data_dir not initialized: call init_data_dir() first") 43 63 } 44 64 45 - /// Create the data directory if it doesn't exist. 65 + /// Create the data directory if it doesn't exist, with 0700 permissions. 46 66 pub fn ensure_data_dir() -> anyhow::Result<()> { 47 - let dir = data_dir(); 48 - if !dir.exists() { 49 - fs::create_dir_all(&dir) 50 - .with_context(|| format!("failed to create data directory: {}", dir.display()))?; 51 - } 52 - Ok(()) 67 + ensure_sensitive_dir(&data_dir()) 53 68 } 54 69 55 70 pub fn save_config(config: &Config) -> anyhow::Result<()> { 56 71 ensure_data_dir()?; 57 72 let content = toml::to_string_pretty(config).context("failed to serialize config")?; 58 - fs::write(data_dir().join("config.toml"), content).context("failed to write config.toml") 73 + write_sensitive_file(&data_dir().join("config.toml"), content) 59 74 } 60 75 61 76 pub fn load_config() -> anyhow::Result<Config> { ··· 75 90 data_dir().join("accounts").join(sanitize_did(did)) 76 91 } 77 92 78 - /// Create the account directory (and parents) if it doesn't exist. 93 + /// Create the account directory (and parents) with 0700 permissions. 79 94 pub fn ensure_account_dir(did: &str) -> anyhow::Result<()> { 80 - let dir = account_dir(did); 81 - if !dir.exists() { 82 - fs::create_dir_all(&dir) 83 - .with_context(|| format!("failed to create account directory: {}", dir.display()))?; 84 - } 85 - Ok(()) 95 + ensure_sensitive_dir(&account_dir(did)) 86 96 } 87 97 88 98 /// Serialize a value to a JSON file inside an account's directory. ··· 90 100 ensure_account_dir(did)?; 91 101 let json = serde_json::to_string_pretty(value) 92 102 .with_context(|| format!("failed to serialize {filename}"))?; 93 - fs::write(account_dir(did).join(filename), json) 103 + write_sensitive_file(&account_dir(did).join(filename), json) 94 104 .with_context(|| format!("failed to write {filename} for {did}")) 95 105 } 96 106
+71
crates/opake-cli/src/config_tests.rs
··· 331 331 ); 332 332 }); 333 333 } 334 + 335 + // -- permission hardening -- 336 + 337 + use std::os::unix::fs::PermissionsExt; 338 + 339 + #[test] 340 + fn write_sensitive_file_sets_0600() { 341 + with_test_dir(|_| { 342 + ensure_data_dir().unwrap(); 343 + let path = data_dir().join("secret.txt"); 344 + write_sensitive_file(&path, "hunter2").unwrap(); 345 + let mode = path.metadata().unwrap().permissions().mode() & 0o777; 346 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 347 + }); 348 + } 349 + 350 + #[test] 351 + fn ensure_sensitive_dir_sets_0700() { 352 + with_test_dir(|dir| { 353 + let target = dir.path().join("secure"); 354 + ensure_sensitive_dir(&target).unwrap(); 355 + let mode = target.metadata().unwrap().permissions().mode() & 0o777; 356 + assert_eq!(mode, 0o700, "expected 0700, got {mode:#o}"); 357 + }); 358 + } 359 + 360 + #[test] 361 + fn ensure_sensitive_dir_tightens_existing() { 362 + with_test_dir(|dir| { 363 + let target = dir.path().join("loose"); 364 + fs::create_dir_all(&target).unwrap(); 365 + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); 366 + 367 + ensure_sensitive_dir(&target).unwrap(); 368 + let mode = target.metadata().unwrap().permissions().mode() & 0o777; 369 + assert_eq!(mode, 0o700, "expected 0700, got {mode:#o}"); 370 + }); 371 + } 372 + 373 + #[test] 374 + fn save_config_sets_0600() { 375 + with_test_dir(|_| { 376 + let config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 377 + save_config(&config).unwrap(); 378 + let mode = data_dir() 379 + .join("config.toml") 380 + .metadata() 381 + .unwrap() 382 + .permissions() 383 + .mode() 384 + & 0o777; 385 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 386 + }); 387 + } 388 + 389 + #[test] 390 + fn save_account_json_sets_0600() { 391 + with_test_dir(|_| { 392 + let did = "did:plc:test"; 393 + let data = serde_json::json!({"key": "value"}); 394 + save_account_json(did, "secret.json", &data).unwrap(); 395 + let mode = account_dir(did) 396 + .join("secret.json") 397 + .metadata() 398 + .unwrap() 399 + .permissions() 400 + .mode() 401 + & 0o777; 402 + assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); 403 + }); 404 + }
+65 -8
crates/opake-cli/src/identity.rs
··· 1 + use std::os::unix::fs::PermissionsExt; 2 + use std::path::Path; 3 + 1 4 use anyhow::Context; 2 5 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 3 6 use log::info; ··· 16 19 17 20 /// Encryption + signing keypairs, stored as base64 in `identity.json`. 18 21 /// The signing fields are optional for backward compat with old identity files. 19 - #[derive(Debug, Serialize, Deserialize)] 22 + #[derive(opake_core::RedactedDebug, Serialize, Deserialize)] 20 23 pub struct Identity { 21 24 pub did: String, 22 25 pub public_key: String, 26 + #[redact] 23 27 pub private_key: String, 24 28 /// Ed25519 signing secret key (base64). 25 29 #[serde(default)] 30 + #[redact] 26 31 pub signing_key: Option<String>, 27 32 /// Ed25519 signing public/verify key (base64). 28 33 #[serde(default)] ··· 90 95 config::save_account_json(did, "identity.json", identity) 91 96 } 92 97 98 + /// Bail if identity.json is readable by group or others (like `ssh -o StrictModes`). 99 + fn check_identity_permissions(path: &Path) -> anyhow::Result<()> { 100 + if !path.exists() { 101 + return Ok(()); 102 + } 103 + let mode = path.metadata()?.permissions().mode(); 104 + if mode & 0o077 != 0 { 105 + anyhow::bail!( 106 + "permissions {:#o} for '{}' are too open — private key material must not be \ 107 + accessible by other users. Run: chmod 600 {}", 108 + mode & 0o777, 109 + path.display(), 110 + path.display(), 111 + ); 112 + } 113 + Ok(()) 114 + } 115 + 93 116 pub fn load_identity(did: &str) -> anyhow::Result<Identity> { 117 + let path = config::account_dir(did).join("identity.json"); 118 + check_identity_permissions(&path)?; 94 119 config::load_account_json(did, "identity.json") 95 120 } 96 121 ··· 153 178 use crate::utils::test_harness::with_test_dir; 154 179 use opake_core::crypto::OsRng; 155 180 use std::collections::BTreeMap; 181 + use std::os::unix::fs::PermissionsExt; 156 182 157 183 fn setup_account(did: &str) { 158 184 let mut accounts = BTreeMap::new(); ··· 237 263 let did = "did:plc:legacy"; 238 264 setup_account(did); 239 265 240 - // Write an old-format identity (no signing keys) 266 + // Write an old-format identity (no signing keys) with correct permissions 241 267 let old_identity = serde_json::json!({ 242 268 "did": did, 243 269 "public_key": BASE64.encode([1u8; 32]), 244 270 "private_key": BASE64.encode([2u8; 32]), 245 271 }); 246 272 config::ensure_account_dir(did).unwrap(); 247 - std::fs::write( 248 - config::account_dir(did).join("identity.json"), 273 + config::write_sensitive_file( 274 + &config::account_dir(did).join("identity.json"), 249 275 serde_json::to_string_pretty(&old_identity).unwrap(), 250 276 ) 251 277 .unwrap(); ··· 270 296 let did = "did:plc:test"; 271 297 setup_account(did); 272 298 config::ensure_account_dir(did).unwrap(); 273 - std::fs::write( 274 - config::account_dir(did).join("identity.json"), 299 + config::write_sensitive_file( 300 + &config::account_dir(did).join("identity.json"), 275 301 "not json {{{", 276 302 ) 277 303 .unwrap(); ··· 285 311 let did = "did:plc:test"; 286 312 setup_account(did); 287 313 config::ensure_account_dir(did).unwrap(); 288 - std::fs::write( 289 - config::account_dir(did).join("identity.json"), 314 + config::write_sensitive_file( 315 + &config::account_dir(did).join("identity.json"), 290 316 r#"{"color": "blue"}"#, 291 317 ) 292 318 .unwrap(); ··· 367 393 }; 368 394 let err = identity.verify_key_bytes().unwrap_err().to_string(); 369 395 assert!(err.contains("16 bytes"), "expected length in error: {err}"); 396 + } 397 + 398 + #[test] 399 + fn load_identity_rejects_world_readable() { 400 + with_test_dir(|_| { 401 + let did = "did:plc:test"; 402 + setup_account(did); 403 + let (identity, _) = ensure_identity(did, &mut OsRng).unwrap(); 404 + assert_eq!(identity.did, did); 405 + 406 + // Loosen permissions to simulate a bad umask 407 + let path = config::account_dir(did).join("identity.json"); 408 + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); 409 + 410 + let err = load_identity(did).unwrap_err().to_string(); 411 + assert!(err.contains("too open"), "expected 'too open': {err}"); 412 + assert!(err.contains("chmod 600"), "expected chmod hint: {err}"); 413 + }); 414 + } 415 + 416 + #[test] 417 + fn load_identity_accepts_0600() { 418 + with_test_dir(|_| { 419 + let did = "did:plc:test"; 420 + setup_account(did); 421 + let (_, _) = ensure_identity(did, &mut OsRng).unwrap(); 422 + 423 + // save_identity goes through write_sensitive_file, so already 0600 424 + let loaded = load_identity(did).unwrap(); 425 + assert_eq!(loaded.did, did); 426 + }); 370 427 } 371 428 372 429 #[test]
+29 -8
crates/opake-cli/src/keyring_store.rs
··· 72 72 } 73 73 74 74 fn save_stored(did: &str, rkey: &str, stored: &StoredKeys) -> anyhow::Result<()> { 75 - let dir = keyrings_dir(did); 76 - if !dir.exists() { 77 - std::fs::create_dir_all(&dir) 78 - .with_context(|| format!("failed to create keyrings dir: {}", dir.display()))?; 79 - } 75 + config::ensure_sensitive_dir(&keyrings_dir(did))?; 80 76 81 77 let json = serde_json::to_string_pretty(stored).context("failed to serialize group key")?; 82 - let path = key_path(did, rkey); 83 - std::fs::write(&path, json) 84 - .with_context(|| format!("failed to write group key: {}", path.display())) 78 + config::write_sensitive_file(&key_path(did, rkey), &json).with_context(|| { 79 + format!( 80 + "failed to write group key: {}", 81 + key_path(did, rkey).display() 82 + ) 83 + }) 85 84 } 86 85 87 86 pub fn save_group_key( ··· 138 137 use crate::utils::test_harness::with_test_dir; 139 138 use opake_core::crypto::{generate_content_key, OsRng}; 140 139 use std::collections::BTreeMap; 140 + use std::os::unix::fs::PermissionsExt; 141 141 142 142 fn setup_account(did: &str) { 143 143 let mut accounts = BTreeMap::new(); ··· 262 262 .unwrap(); 263 263 let err = load_group_key(did, "short", 0).unwrap_err(); 264 264 assert!(err.to_string().contains("16 bytes"), "got: {err}"); 265 + }); 266 + } 267 + 268 + #[test] 269 + fn save_group_key_sets_permissions() { 270 + with_test_dir(|_| { 271 + let did = "did:plc:test"; 272 + setup_account(did); 273 + let group_key = generate_content_key(&mut OsRng); 274 + save_group_key(did, "tid123", 0, &group_key).unwrap(); 275 + 276 + let dir_mode = keyrings_dir(did).metadata().unwrap().permissions().mode() & 0o777; 277 + assert_eq!(dir_mode, 0o700, "expected dir 0700, got {dir_mode:#o}"); 278 + 279 + let file_mode = key_path(did, "tid123") 280 + .metadata() 281 + .unwrap() 282 + .permissions() 283 + .mode() 284 + & 0o777; 285 + assert_eq!(file_mode, 0o600, "expected file 0600, got {file_mode:#o}"); 265 286 }); 266 287 } 267 288
+1
crates/opake-core/Cargo.toml
··· 9 9 test-utils = [] 10 10 11 11 [dependencies] 12 + opake-derive = { path = "../opake-derive" } 12 13 base64.workspace = true 13 14 ed25519-dalek.workspace = true 14 15 log.workspace = true
+3 -1
crates/opake-core/src/client/xrpc/mod.rs
··· 18 18 // --------------------------------------------------------------------------- 19 19 20 20 /// An authenticated session with a PDS. 21 - #[derive(Debug, Clone, Serialize, Deserialize)] 21 + #[derive(Clone, crate::RedactedDebug, Serialize, Deserialize)] 22 22 #[serde(rename_all = "camelCase")] 23 23 pub struct Session { 24 24 pub did: String, 25 25 pub handle: String, 26 + #[redact] 26 27 pub access_jwt: String, 28 + #[redact] 27 29 pub refresh_jwt: String, 28 30 } 29 31
+36 -2
crates/opake-core/src/crypto/mod.rs
··· 38 38 const WRAPPED_KEY_LEN: usize = CONTENT_KEY_LEN + AES_KW_OVERHEAD; 39 39 const CIPHERTEXT_LEN: usize = X25519_KEY_LEN + WRAPPED_KEY_LEN; 40 40 41 + /// Wrapper that prints byte length instead of content in Debug output. 42 + /// Used by the `RedactedDebug` derive macro for `#[redact]` fields. 43 + pub struct Redacted<'a, T: ?Sized>(pub &'a T); 44 + 45 + impl std::fmt::Debug for Redacted<'_, String> { 46 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 47 + write!(f, "[{} bytes]", self.0.len()) 48 + } 49 + } 50 + 51 + impl std::fmt::Debug for Redacted<'_, Option<String>> { 52 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 53 + match self.0 { 54 + Some(s) => write!(f, "Some([{} bytes])", s.len()), 55 + None => write!(f, "None"), 56 + } 57 + } 58 + } 59 + 60 + impl std::fmt::Debug for Redacted<'_, Vec<u8>> { 61 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 62 + write!(f, "[{} bytes]", self.0.len()) 63 + } 64 + } 65 + 66 + impl<const N: usize> std::fmt::Debug for Redacted<'_, [u8; N]> { 67 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 68 + write!(f, "[{N} bytes]") 69 + } 70 + } 71 + 41 72 /// A 256-bit AES content encryption key. 42 - #[derive(Debug)] 43 - pub struct ContentKey(pub [u8; CONTENT_KEY_LEN]); 73 + #[derive(crate::RedactedDebug)] 74 + pub struct ContentKey(#[redact] pub [u8; CONTENT_KEY_LEN]); 44 75 45 76 /// An X25519 public key: 32 raw bytes. 46 77 pub type X25519PublicKey = [u8; X25519_KEY_LEN]; ··· 52 83 pub type DidPublicKey<'a> = (&'a str, &'a X25519PublicKey); 53 84 54 85 /// The result of encrypting plaintext content. 86 + #[derive(crate::RedactedDebug)] 55 87 pub struct EncryptedPayload { 88 + #[redact] 56 89 pub ciphertext: Vec<u8>, 90 + #[redact] 57 91 pub nonce: [u8; AES_GCM_NONCE_LEN], 58 92 } 59 93
+9
crates/opake-core/src/lib.rs
··· 8 8 // a reqwest-based implementation, the SPA provides one using browser fetch. 9 9 // Crypto is synchronous and pure. Records are just types. 10 10 11 + // Allows `::opake_core::crypto::Redacted` to resolve inside this crate, 12 + // matching the path the RedactedDebug derive macro generates. 13 + extern crate self as opake_core; 14 + 15 + pub use opake_derive::RedactedDebug; 16 + 11 17 pub mod atproto; 12 18 pub mod client; 13 19 pub mod crypto; ··· 21 27 22 28 #[cfg(any(test, feature = "test-utils"))] 23 29 pub mod test_utils; 30 + 31 + #[cfg(test)] 32 + mod redacted_debug_tests;
+119
crates/opake-core/src/redacted_debug_tests.rs
··· 1 + use crate::crypto::{encrypt_blob, generate_content_key, OsRng}; 2 + 3 + // -- Test structs -- 4 + 5 + #[derive(crate::RedactedDebug)] 6 + struct NamedAllRedacted { 7 + #[redact] 8 + secret: String, 9 + #[redact] 10 + key: Vec<u8>, 11 + } 12 + 13 + #[derive(crate::RedactedDebug)] 14 + struct NamedMixed { 15 + public: String, 16 + #[redact] 17 + secret: String, 18 + visible: u32, 19 + } 20 + 21 + #[derive(crate::RedactedDebug)] 22 + struct NamedWithOption { 23 + #[redact] 24 + maybe_secret: Option<String>, 25 + } 26 + 27 + #[derive(crate::RedactedDebug)] 28 + struct RedactedNewtype(#[redact] [u8; 16]); 29 + 30 + #[derive(crate::RedactedDebug)] 31 + struct TransparentNewtype(u32); 32 + 33 + // -- Named struct tests -- 34 + 35 + #[test] 36 + fn named_hides_all_redacted_fields() { 37 + let s = NamedAllRedacted { 38 + secret: "hunter2".into(), 39 + key: vec![0xAB; 64], 40 + }; 41 + let out = format!("{s:?}"); 42 + assert!(!out.contains("hunter2"), "secret leaked: {out}"); 43 + assert!(out.contains("[7 bytes]"), "expected length: {out}"); 44 + assert!(out.contains("[64 bytes]"), "expected length: {out}"); 45 + } 46 + 47 + #[test] 48 + fn named_shows_public_hides_redacted() { 49 + let s = NamedMixed { 50 + public: "hello".into(), 51 + secret: "shhh".into(), 52 + visible: 42, 53 + }; 54 + let out = format!("{s:?}"); 55 + assert!(out.contains("hello"), "public field missing: {out}"); 56 + assert!(out.contains("42"), "visible field missing: {out}"); 57 + assert!(!out.contains("shhh"), "secret leaked: {out}"); 58 + assert!(out.contains("[4 bytes]"), "expected length: {out}"); 59 + } 60 + 61 + // -- Option<String> tests -- 62 + 63 + #[test] 64 + fn option_some_shows_length() { 65 + let s = NamedWithOption { 66 + maybe_secret: Some("password".into()), 67 + }; 68 + let out = format!("{s:?}"); 69 + assert!(!out.contains("password"), "secret leaked: {out}"); 70 + assert!(out.contains("Some([8 bytes])"), "expected Some(len): {out}"); 71 + } 72 + 73 + #[test] 74 + fn option_none_shows_none() { 75 + let s = NamedWithOption { maybe_secret: None }; 76 + let out = format!("{s:?}"); 77 + assert!(out.contains("None"), "expected None: {out}"); 78 + } 79 + 80 + // -- Newtype tests -- 81 + 82 + #[test] 83 + fn newtype_redacted_hides_bytes() { 84 + let s = RedactedNewtype([0xFF; 16]); 85 + let out = format!("{s:?}"); 86 + assert!(!out.contains("255"), "raw bytes leaked: {out}"); 87 + assert!(out.contains("[16 bytes]"), "expected length: {out}"); 88 + } 89 + 90 + #[test] 91 + fn newtype_transparent_shows_value() { 92 + let s = TransparentNewtype(99); 93 + let out = format!("{s:?}"); 94 + assert!(out.contains("99"), "value missing: {out}"); 95 + } 96 + 97 + // -- Real type tests -- 98 + 99 + #[test] 100 + fn content_key_shows_length_not_bytes() { 101 + let key = generate_content_key(&mut OsRng); 102 + let out = format!("{key:?}"); 103 + assert!(out.starts_with("ContentKey"), "expected type name: {out}"); 104 + assert!(out.contains("[32 bytes]"), "expected length: {out}"); 105 + } 106 + 107 + #[test] 108 + fn encrypted_payload_shows_lengths() { 109 + let key = generate_content_key(&mut OsRng); 110 + let payload = encrypt_blob(&key, b"test", &mut OsRng).unwrap(); 111 + let out = format!("{payload:?}"); 112 + assert!( 113 + out.contains("EncryptedPayload"), 114 + "expected type name: {out}" 115 + ); 116 + assert!(out.contains("[12 bytes]"), "expected nonce length: {out}"); 117 + // ciphertext is longer than plaintext due to GCM tag 118 + assert!(out.contains("bytes]"), "expected ciphertext length: {out}"); 119 + }
+14
crates/opake-derive/Cargo.toml
··· 1 + [package] 2 + name = "opake-derive" 3 + description = "Derive macros for Opake — RedactedDebug" 4 + edition.workspace = true 5 + version.workspace = true 6 + license.workspace = true 7 + 8 + [lib] 9 + proc-macro = true 10 + 11 + [dependencies] 12 + syn = { version = "2", features = ["derive"] } 13 + quote = "1" 14 + proc-macro2 = "1"
+118
crates/opake-derive/src/lib.rs
··· 1 + // Derive macros for Opake. 2 + // 3 + // RedactedDebug generates a Debug impl that shows byte length instead of 4 + // content for fields marked `#[redact]`. Works on named structs and 5 + // newtypes. Redacted fields display as `[N bytes]` for sized types and 6 + // `Some([N bytes])` / `None` for Options. 7 + 8 + use proc_macro::TokenStream; 9 + use quote::quote; 10 + use syn::{parse_macro_input, Data, DeriveInput, Fields}; 11 + 12 + /// Derive a `Debug` impl that redacts fields marked with `#[redact]`. 13 + /// 14 + /// Redacted fields show their byte length instead of content, using 15 + /// `opake_core::crypto::Redacted` as the formatting wrapper. 16 + /// 17 + /// # Named structs 18 + /// 19 + /// ```ignore 20 + /// #[derive(RedactedDebug)] 21 + /// pub struct Session { 22 + /// pub did: String, 23 + /// pub handle: String, 24 + /// #[redact] pub access_jwt: String, 25 + /// #[redact] pub refresh_jwt: String, 26 + /// } 27 + /// // Debug output: Session { did: "...", handle: "...", access_jwt: [187 bytes], refresh_jwt: [253 bytes] } 28 + /// ``` 29 + /// 30 + /// # Tuple structs (newtypes) 31 + /// 32 + /// ```ignore 33 + /// #[derive(RedactedDebug)] 34 + /// pub struct ContentKey(#[redact] pub [u8; 32]); 35 + /// // Debug output: ContentKey([32 bytes]) 36 + /// ``` 37 + #[proc_macro_derive(RedactedDebug, attributes(redact))] 38 + pub fn redacted_debug_derive(input: TokenStream) -> TokenStream { 39 + let input = parse_macro_input!(input as DeriveInput); 40 + let name = &input.ident; 41 + 42 + let body = match &input.data { 43 + Data::Struct(data) => match &data.fields { 44 + Fields::Named(fields) => { 45 + let field_stmts: Vec<_> = fields 46 + .named 47 + .iter() 48 + .map(|f| { 49 + let field_name = f.ident.as_ref().unwrap(); 50 + let is_redacted = f.attrs.iter().any(|a| a.path().is_ident("redact")); 51 + if is_redacted { 52 + quote! { 53 + s.field( 54 + stringify!(#field_name), 55 + &::opake_core::crypto::Redacted(&self.#field_name), 56 + ); 57 + } 58 + } else { 59 + quote! { 60 + s.field(stringify!(#field_name), &self.#field_name); 61 + } 62 + } 63 + }) 64 + .collect(); 65 + 66 + quote! { 67 + let mut s = f.debug_struct(stringify!(#name)); 68 + #(#field_stmts)* 69 + s.finish() 70 + } 71 + } 72 + Fields::Unnamed(fields) => { 73 + let field_stmts: Vec<_> = fields 74 + .unnamed 75 + .iter() 76 + .enumerate() 77 + .map(|(i, f)| { 78 + let index = syn::Index::from(i); 79 + let is_redacted = f.attrs.iter().any(|a| a.path().is_ident("redact")); 80 + if is_redacted { 81 + quote! { 82 + s.field(&::opake_core::crypto::Redacted(&self.#index)); 83 + } 84 + } else { 85 + quote! { 86 + s.field(&self.#index); 87 + } 88 + } 89 + }) 90 + .collect(); 91 + 92 + quote! { 93 + let mut s = f.debug_tuple(stringify!(#name)); 94 + #(#field_stmts)* 95 + s.finish() 96 + } 97 + } 98 + Fields::Unit => { 99 + quote! { f.write_str(stringify!(#name)) } 100 + } 101 + }, 102 + _ => { 103 + return syn::Error::new_spanned(&input, "RedactedDebug only supports structs") 104 + .to_compile_error() 105 + .into(); 106 + } 107 + }; 108 + 109 + let expanded = quote! { 110 + impl ::std::fmt::Debug for #name { 111 + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { 112 + #body 113 + } 114 + } 115 + }; 116 + 117 + expanded.into() 118 + }