Skip to main content

bitwarden_vault/cipher/blob/
sealed.rs

1use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
2use bitwarden_crypto::{
3    EncString, KeyStoreContext,
4    safe::{DataEnvelope, DataEnvelopeError},
5};
6use bitwarden_logging::instrument;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use super::CipherBlob;
11
12const FORMAT_VERSION: u8 = 1;
13
14/// Error type for `SealedCipherBlob` operations.
15#[derive(Debug, Error)]
16pub enum SealedCipherBlobError {
17    /// The format version is newer or older than this client supports.
18    #[error("Unsupported format version: {0}")]
19    UnsupportedFormatVersion(u8),
20    /// Serializing the sealed container to JSON failed.
21    #[error("JSON encoding error")]
22    JsonEncoding,
23    /// The string did not parse as a JSON object carrying a `format_version` key.
24    #[error("JSON decoding error")]
25    JsonDecoding,
26    /// The inner `DataEnvelope` could not be sealed or opened.
27    #[error(transparent)]
28    DataEnvelope(#[from] DataEnvelopeError),
29}
30
31/// Sealed container that packages a wrapped CEK and encrypted `DataEnvelope` together.
32///
33/// Serializable into the `Cipher.data: Option<String>` field.
34#[derive(Serialize, Deserialize, Debug, Clone)]
35pub(crate) struct SealedCipherBlob {
36    format_version: u8,
37    wrapped_cek: EncString,
38    envelope: DataEnvelope,
39}
40
41impl SealedCipherBlob {
42    /// Seals a `CipherBlob` into a `SealedCipherBlob` by encrypting it with a new CEK
43    /// wrapped by the provided wrapping key.
44    pub(super) fn seal(
45        data: CipherBlob,
46        wrapping_key: &SymmetricKeySlotId,
47        ctx: &mut KeyStoreContext<KeySlotIds>,
48    ) -> Result<Self, SealedCipherBlobError> {
49        let (envelope, wrapped_cek) =
50            DataEnvelope::seal_with_wrapping_key(data, wrapping_key, ctx)?;
51        Ok(Self {
52            format_version: FORMAT_VERSION,
53            wrapped_cek,
54            envelope,
55        })
56    }
57
58    /// Unseals the `CipherBlob` from this container using the provided wrapping key.
59    #[instrument(err, fields(format_version = self.format_version))]
60    pub(super) fn unseal(
61        &self,
62        wrapping_key: &SymmetricKeySlotId,
63        ctx: &mut KeyStoreContext<KeySlotIds>,
64    ) -> Result<CipherBlob, SealedCipherBlobError> {
65        if self.format_version != FORMAT_VERSION {
66            return Err(SealedCipherBlobError::UnsupportedFormatVersion(
67                self.format_version,
68            ));
69        }
70        Ok(self
71            .envelope
72            .unseal_with_wrapping_key(wrapping_key, &self.wrapped_cek, ctx)?)
73    }
74
75    /// Serializes this container into an opaque JSON string.
76    pub(super) fn to_opaque_string(&self) -> Result<String, SealedCipherBlobError> {
77        serde_json::to_string(self).map_err(|_| SealedCipherBlobError::JsonEncoding)
78    }
79
80    /// Deserializes a `SealedCipherBlob` from an opaque JSON string.
81    ///
82    /// Requires the JSON to be an object carrying a `format_version` key; legacy
83    /// field-level `CipherData` never contains it, so it is rejected here.
84    pub(super) fn from_opaque_string(s: &str) -> Result<Self, SealedCipherBlobError> {
85        let value: serde_json::Value =
86            serde_json::from_str(s).map_err(|_| SealedCipherBlobError::JsonDecoding)?;
87        if !value
88            .as_object()
89            .is_some_and(|o| o.contains_key("format_version"))
90        {
91            return Err(SealedCipherBlobError::JsonDecoding);
92        }
93        serde_json::from_value(value).map_err(|_| SealedCipherBlobError::JsonDecoding)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use bitwarden_core::key_management::KeySlotIds;
100    use bitwarden_crypto::{KeyStore, SymmetricCryptoKey};
101    use bitwarden_encoding::B64;
102
103    use super::*;
104    use crate::cipher::{blob::v1::*, secure_note::SecureNoteType};
105
106    fn test_cipher_blob() -> CipherBlob {
107        CipherBlobV1 {
108            name: "Test Cipher".to_string(),
109            notes: Some("Some notes".to_string()),
110            type_data: CipherTypeDataV1::SecureNote(SecureNoteDataV1 {
111                r#type: SecureNoteType::Generic,
112            }),
113            fields: Vec::new(),
114            password_history: Vec::new(),
115        }
116        .into()
117    }
118
119    #[test]
120    fn test_seal_unseal_round_trip() {
121        let store: KeyStore<KeySlotIds> = KeyStore::default();
122        let mut ctx = store.context_mut();
123        let wrapping_key = ctx.generate_symmetric_key();
124
125        let sealed = SealedCipherBlob::seal(test_cipher_blob(), &wrapping_key, &mut ctx).unwrap();
126        let unsealed = sealed.unseal(&wrapping_key, &mut ctx).unwrap();
127
128        assert_eq!(test_cipher_blob(), unsealed);
129    }
130
131    #[test]
132    fn test_opaque_string_round_trip() {
133        let store: KeyStore<KeySlotIds> = KeyStore::default();
134        let mut ctx = store.context_mut();
135        let wrapping_key = ctx.generate_symmetric_key();
136
137        let sealed = SealedCipherBlob::seal(test_cipher_blob(), &wrapping_key, &mut ctx).unwrap();
138
139        let opaque = sealed.to_opaque_string().unwrap();
140        let restored = SealedCipherBlob::from_opaque_string(&opaque).unwrap();
141        let unsealed = restored.unseal(&wrapping_key, &mut ctx).unwrap();
142
143        assert_eq!(test_cipher_blob(), unsealed);
144    }
145
146    #[test]
147    fn test_unsupported_format_version() {
148        let store: KeyStore<KeySlotIds> = KeyStore::default();
149        let mut ctx = store.context_mut();
150        let wrapping_key = ctx.generate_symmetric_key();
151
152        let blob = test_cipher_blob();
153        let mut sealed = SealedCipherBlob::seal(blob, &wrapping_key, &mut ctx).unwrap();
154        sealed.format_version = 99;
155
156        let result = sealed.unseal(&wrapping_key, &mut ctx);
157        assert!(matches!(
158            result,
159            Err(SealedCipherBlobError::UnsupportedFormatVersion(99))
160        ));
161    }
162
163    #[test]
164    fn test_invalid_json() {
165        let result = SealedCipherBlob::from_opaque_string("not json");
166        assert!(matches!(result, Err(SealedCipherBlobError::JsonDecoding)));
167    }
168
169    #[test]
170    fn test_missing_format_version() {
171        let result = SealedCipherBlob::from_opaque_string("{\"Name\":\"x\"}");
172        assert!(matches!(result, Err(SealedCipherBlobError::JsonDecoding)));
173    }
174
175    #[test]
176    #[ignore]
177    fn generate_sealed_test_vector() {
178        let store: KeyStore<KeySlotIds> = KeyStore::default();
179        let mut ctx = store.context_mut();
180        let wrapping_key = ctx.generate_symmetric_key();
181
182        let sealed = SealedCipherBlob::seal(test_cipher_blob(), &wrapping_key, &mut ctx).unwrap();
183        let opaque = sealed.to_opaque_string().unwrap();
184
185        #[allow(deprecated)]
186        let key = ctx.dangerous_get_symmetric_key(wrapping_key).unwrap();
187        println!(
188            "const TEST_VECTOR_WRAPPING_KEY: &str = \"{}\";",
189            key.to_base64()
190        );
191        println!("const TEST_VECTOR_SEALED_BLOB: &str = \"{}\";", opaque);
192    }
193
194    const TEST_VECTOR_WRAPPING_KEY: &str =
195        "z27dMz/RK4wboY/Ako0YVFr9jaiSjgQQyGkTZ4LIuNrOXyeDAjeD41qbhVKl0OSjP3QuN9xmAJQE8+V5/Tl7ig==";
196    const TEST_VECTOR_SEALED_BLOB: &str = r#"{"format_version":1,"wrapped_cek":"2.LQJf2BbznXX+NelBY4pSJg==|txMmjZEOhSMA7Jrm+rZt1LDfA6s3G2QU5Z8MqO4nG9s2ZXuzSLU/iYOUXD8xw+eHVSu7IUHu1LsCm4SLf+ZhkX5QIo4hJT3DHSbgu6VPUC0=|yuU/EWQWyihf2Yh9lQ1NP+zTROEpnXoRS//GfxDgC4k=","envelope":"g1hLpQE6AAERbwN4I2FwcGxpY2F0aW9uL3guYml0d2FyZGVuLmNib3ItcGFkZGVkBFBoHnjLne8MPV72YPXuskd6OgABOIECOgABOIABoQVYGA00vxb7gF7Y3SUyoCMy34C1HrB3fSY3jVhxZXQmmotGEIwwRlG+SpTcyTl5m4lUnozWrjAYfWitl1+cz457Wq3iDW/MvrHE7c1g38QJxY6t1yhQL0dQy9DyDXQDiWGPtYzic2Ay+GtrlIERN37wOdhQ1HZDeoobHL+aKomvPTems/Ta2SqWC9HfE38="}"#;
197
198    #[test]
199    fn test_recorded_sealed_blob_test_vector() {
200        let wrapping_key =
201            SymmetricCryptoKey::try_from(B64::try_from(TEST_VECTOR_WRAPPING_KEY).unwrap()).unwrap();
202
203        let store: KeyStore<KeySlotIds> = KeyStore::default();
204        let mut ctx = store.context_mut();
205        let wrapping_key_id = ctx.add_local_symmetric_key(wrapping_key);
206
207        let sealed = SealedCipherBlob::from_opaque_string(TEST_VECTOR_SEALED_BLOB).expect(
208            "SealedCipherBlob container format has changed in a backwards-incompatible way. \
209             Existing sealed data must remain deserializable.",
210        );
211        let unsealed = sealed.unseal(&wrapping_key_id, &mut ctx).expect(
212            "SealedCipherBlob container format has changed in a backwards-incompatible way. \
213             Existing sealed data must remain deserializable.",
214        );
215
216        assert_eq!(test_cipher_blob(), unsealed);
217    }
218}