Skip to main content

hashiverse_lib/client/meta_post/
meta_post_crypto.rs

1//! # Encryption of the private meta-post section
2//!
3//! The private half of a [`crate::client::meta_post::meta_post::MetaPostV1`]
4//! (feedback thresholds, warning toggles, anything the account holder doesn't want
5//! public) is symmetric-encrypted with a key derived from the account's own private
6//! signing key: `sig = sign(signature_key, constant)` then `key = blake3(sig)`.
7//!
8//! Because signing requires the private key, only the account holder can recover the
9//! symmetric key; because the key is deterministic, every device that unlocks the same
10//! [`crate::tools::keys::Keys`] derives the same key and can read/write the same
11//! private section. No key exchange, no on-network secrets.
12//!
13//! The `encrypt_private_section` / `decrypt_private_section` pair here is the only
14//! place this derivation happens.
15
16use crate::client::key_locker::key_locker::KeyLocker;
17use crate::client::meta_post::meta_post::MetaPostPrivateV1;
18use crate::tools::types::Salt;
19use crate::tools::{compression, encryption, json};
20
21const META_POST_ENCRYPTION_CONTEXT: &[u8] = b"hashiverse-meta-post-encryption";
22
23/// Derive a 32-byte symmetric encryption key by signing a well-known
24/// constant concatenated with the provided salt, then hashing the
25/// signature with blake3.
26///
27/// Only the holder of the private signing key can reproduce this key,
28/// making it suitable for encrypting data that only the user should read.
29pub async fn derive_meta_post_encryption_key(key_locker: &dyn KeyLocker, salt: &Salt) -> anyhow::Result<Vec<u8>> {
30    let mut message = Vec::with_capacity(META_POST_ENCRYPTION_CONTEXT.len() + salt.as_ref().len());
31    message.extend_from_slice(META_POST_ENCRYPTION_CONTEXT);
32    message.extend_from_slice(salt.as_ref());
33
34    let signature = key_locker.sign(&message).await?;
35    let key_hash = blake3::hash(signature.as_ref());
36    Ok(key_hash.as_bytes().to_vec())
37}
38
39/// Encrypt a `MetaPostPrivateV1` into a hex-encoded string.
40pub async fn encrypt_private_section(key_locker: &dyn KeyLocker, salt: &Salt, private: &MetaPostPrivateV1) -> anyhow::Result<String> {
41    let symmetric_key = derive_meta_post_encryption_key(key_locker, salt).await?;
42    let plaintext = json::struct_to_bytes(private)?;
43    let compressed = compression::compress_for_size(&plaintext)?.to_bytes();
44    let encrypted = encryption::encrypt_strong(&compressed, &vec![symmetric_key])?;
45    Ok(hex::encode(encrypted))
46}
47
48/// Decrypt a hex-encoded string back into a `MetaPostPrivateV1`.
49pub async fn decrypt_private_section(key_locker: &dyn KeyLocker, salt: &Salt, encrypted_hex: &str) -> anyhow::Result<MetaPostPrivateV1> {
50    let symmetric_key = derive_meta_post_encryption_key(key_locker, salt).await?;
51    let encrypted = hex::decode(encrypted_hex)?;
52    let compressed = encryption::decrypt(&encrypted, &symmetric_key)?;
53    let plaintext = compression::decompress(&compressed)?.to_bytes();
54    let private: MetaPostPrivateV1 = json::bytes_to_struct(&plaintext)?;
55    Ok(private)
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::client::key_locker::mem_key_locker::MemKeyLockerManager;
62    use crate::client::key_locker::key_locker::KeyLockerManager;
63    use crate::client::meta_post::meta_post::VersionedField;
64    use crate::tools::time::TimeMillis;
65
66    fn t(millis: i64) -> TimeMillis { TimeMillis(millis) }
67
68    #[tokio::test]
69    async fn encrypt_decrypt_private_section_roundtrip() -> anyhow::Result<()> {
70        let key_locker_manager = MemKeyLockerManager::new().await?;
71        let key_locker = key_locker_manager.create("test_phrase".to_string()).await?;
72        let salt = Salt::random();
73
74        let mut private = MetaPostPrivateV1::empty();
75        private.followed_client_ids.insert("client_abc".to_string(), VersionedField::new(true, t(100)));
76        private.followed_hashtags.insert("rust".to_string(), VersionedField::new(true, t(200)));
77        private.content_thresholds.insert(101, VersionedField::new(100, t(300)));
78        private.skip_warnings_for_followed = VersionedField::new(true, t(400));
79
80        let encrypted_hex = encrypt_private_section(key_locker.as_ref(), &salt, &private).await?;
81        let decrypted = decrypt_private_section(key_locker.as_ref(), &salt, &encrypted_hex).await?;
82
83        assert_eq!(private, decrypted);
84        Ok(())
85    }
86
87    #[tokio::test]
88    async fn different_salt_produces_different_key() -> anyhow::Result<()> {
89        let key_locker_manager = MemKeyLockerManager::new().await?;
90        let key_locker = key_locker_manager.create("test_phrase".to_string()).await?;
91
92        let salt_a = Salt::random();
93        let salt_b = Salt::random();
94
95        let key_a = derive_meta_post_encryption_key(key_locker.as_ref(), &salt_a).await?;
96        let key_b = derive_meta_post_encryption_key(key_locker.as_ref(), &salt_b).await?;
97
98        assert_ne!(key_a, key_b);
99        Ok(())
100    }
101
102    #[tokio::test]
103    async fn same_salt_produces_same_key() -> anyhow::Result<()> {
104        let key_locker_manager = MemKeyLockerManager::new().await?;
105        let key_locker = key_locker_manager.create("test_phrase".to_string()).await?;
106
107        let salt = Salt::random();
108
109        let key_1 = derive_meta_post_encryption_key(key_locker.as_ref(), &salt).await?;
110        let key_2 = derive_meta_post_encryption_key(key_locker.as_ref(), &salt).await?;
111
112        assert_eq!(key_1, key_2);
113        Ok(())
114    }
115
116    #[tokio::test]
117    async fn different_user_cannot_decrypt() -> anyhow::Result<()> {
118        let key_locker_manager = MemKeyLockerManager::new().await?;
119        let key_locker_a = key_locker_manager.create("client_a_phrase".to_string()).await?;
120        let key_locker_b = key_locker_manager.create("client_b_phrase".to_string()).await?;
121
122        let salt = Salt::random();
123        let private = MetaPostPrivateV1::empty();
124
125        let encrypted_hex = encrypt_private_section(key_locker_a.as_ref(), &salt, &private).await?;
126        let decrypt_result = decrypt_private_section(key_locker_b.as_ref(), &salt, &encrypted_hex).await;
127
128        assert!(decrypt_result.is_err(), "Different user should not be able to decrypt");
129        Ok(())
130    }
131
132    #[tokio::test]
133    async fn encrypt_decrypt_with_tombstones() -> anyhow::Result<()> {
134        let key_locker_manager = MemKeyLockerManager::new().await?;
135        let key_locker = key_locker_manager.create("test_phrase".to_string()).await?;
136        let salt = Salt::random();
137
138        let mut private = MetaPostPrivateV1::empty();
139        private.followed_client_ids.insert("deleted_client".to_string(), VersionedField::tombstone(t(999)));
140        private.skip_warnings_for_followed = VersionedField::tombstone(t(888));
141
142        let encrypted_hex = encrypt_private_section(key_locker.as_ref(), &salt, &private).await?;
143        let decrypted = decrypt_private_section(key_locker.as_ref(), &salt, &encrypted_hex).await?;
144
145        assert_eq!(private, decrypted);
146        assert!(decrypted.followed_client_ids["deleted_client"].is_tombstone());
147        assert!(decrypted.skip_warnings_for_followed.is_tombstone());
148        Ok(())
149    }
150}