Skip to main content

bitwarden_importers/importers/onepassword/access/
account_key.rs

1//! Account Key (Secret Key): A2/A3 parse, HKDF-SHA256 hash, and XOR combine.
2
3use zeroize::Zeroize;
4
5use super::{error::OnePasswordError, kdf};
6
7/// The characters an Account Key is written in: base32 without the confusable `0`, `1`, `I`, `L`,
8/// `O` and `U`. Everything else in the input is dropped, including the dashes.
9const ALPHABET: &str = "23456789ABCDEFGHJKLMNPQRSTVWXYZ";
10
11/// A parsed 1Password Account Key (also called the Secret Key), split into its format, uuid, and
12/// key.
13pub(super) struct AccountKey {
14    pub format: String,
15    pub uuid: String,
16    pub key: String,
17}
18
19impl Drop for AccountKey {
20    fn drop(&mut self) {
21        self.key.zeroize();
22    }
23}
24
25impl AccountKey {
26    /// Parses a key string such as `A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9`, splitting it into
27    /// `format` (2), `uuid` (6), and `key` (the rest).
28    ///
29    /// The web client's `SecretKey.fromInput` uppercases and then drops every character outside
30    /// [`ALPHABET`], so dashes, whitespace and anything else a paste drags along are all forgiven.
31    pub(super) fn parse(input: &str) -> Result<AccountKey, OnePasswordError> {
32        let s: String = input
33            .to_uppercase()
34            .chars()
35            .filter(|c| ALPHABET.contains(*c))
36            .collect();
37
38        let Some(format) = s.get(..2) else {
39            return Err(OnePasswordError::Internal(format!(
40                "invalid account key: too short, got {}",
41                s.len()
42            )));
43        };
44
45        // Only A3 has ever been seen on a real account. A2 comes from reverse-engineered code and
46        // is untested against anything, so treat its 33-byte length as unverified.
47        match format {
48            "A2" if s.len() == 33 => {}
49            "A3" if s.len() == 34 => {}
50            "A2" => {
51                return Err(OnePasswordError::Internal(format!(
52                    "invalid account key: 'A2' needs 33 characters without dashes, got {}",
53                    s.len()
54                )));
55            }
56            "A3" => {
57                return Err(OnePasswordError::Internal(format!(
58                    "invalid account key: 'A3' needs 34 characters without dashes, got {}",
59                    s.len()
60                )));
61            }
62            _ => {
63                return Err(OnePasswordError::Internal(format!(
64                    "invalid account key: unknown format '{format}'"
65                )));
66            }
67        }
68
69        let invalid = || OnePasswordError::Internal("invalid account key".into());
70        Ok(AccountKey {
71            format: format.to_string(),
72            uuid: s.get(2..8).ok_or_else(invalid)?.to_string(),
73            key: s.get(8..).ok_or_else(invalid)?.to_string(),
74        })
75    }
76
77    /// `HKDF-SHA256(ikm = key, salt = uuid, info = format)`, 32 bytes.
78    pub(super) fn hash(&self) -> [u8; 32] {
79        kdf::hkdf_sha256(&self.format, self.key.as_bytes(), self.uuid.as_bytes())
80    }
81
82    /// XORs the hash with `bytes`, which must be exactly 32 bytes long.
83    pub(super) fn combine_with(&self, bytes: &[u8]) -> Result<[u8; 32], OnePasswordError> {
84        let mut h = self.hash();
85        if h.len() != bytes.len() {
86            return Err(OnePasswordError::Internal(
87                "size doesn't match hash function".into(),
88            ));
89        }
90
91        for (byte, other) in h.iter_mut().zip(bytes) {
92            *byte ^= other;
93        }
94
95        Ok(h)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use data_encoding::BASE64URL_NOPAD;
102
103    use super::*;
104
105    fn key() -> AccountKey {
106        AccountKey {
107            format: "A3".into(),
108            uuid: "RTN9SA".into(),
109            key: "DY9445Y5FF96X6E7B5GPFA95R9".into(),
110        }
111    }
112
113    #[test]
114    fn parse_returns_parsed_format_a3_key() {
115        let key = AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid key");
116        assert_eq!(key.format, "A3");
117        assert_eq!(key.uuid, "RTN9SA");
118        assert_eq!(key.key, "DY9445Y5FF96X6E7B5GPFA95R9");
119    }
120
121    /// The web client drops anything outside the alphabet, so a paste that drags whitespace or
122    /// stray punctuation along still parses.
123    #[test]
124    fn parse_ignores_everything_outside_the_alphabet() {
125        let cases = [
126            "  A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9\n",
127            "a3-rtn9sa-dy9445y5ff96x6e7b5gpfa95r9",
128            "A3 RTN9SA DY9445Y 5FF96X6 E7B5GPF A95R9",
129            "A3_RTN9SA_DY9445Y5FF96X6E7B5GPFA95R9",
130        ];
131        for case in cases {
132            let key = AccountKey::parse(case).unwrap_or_else(|e| panic!("{case:?}: {e}"));
133            assert_eq!(key.format, "A3");
134            assert_eq!(key.uuid, "RTN9SA");
135            assert_eq!(key.key, "DY9445Y5FF96X6E7B5GPFA95R9");
136        }
137    }
138
139    // Made up: no real A2 key was ever available to test against.
140    #[test]
141    fn parse_returns_parsed_format_a2_key() {
142        let key = AccountKey::parse("A2-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R").expect("valid key");
143        assert_eq!(key.format, "A2");
144        assert_eq!(key.uuid, "RTN9SA");
145        assert_eq!(key.key, "DY9445Y5FF96X6E7B5GPFA95R");
146    }
147
148    #[test]
149    fn parse_throws_on_invalid_key_format() {
150        let cases = [
151            "",
152            "A",
153            "A2",
154            "A3",
155            "A2-RTN9SA-DY9445Y5FF96X6E7B5GPFA95",
156            "A2-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9",
157            "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R",
158            "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R99",
159            "A3-RTN9SA-DY9445Y-FF96X6E7B-GPFA95R9",
160        ];
161        for case in cases {
162            match AccountKey::parse(case) {
163                Ok(_) => panic!("expected {case:?} to be invalid"),
164                Err(err) => assert!(
165                    err.to_string().contains("invalid account key"),
166                    "unexpected error for {case:?}: {err}"
167                ),
168            }
169        }
170    }
171
172    #[test]
173    fn hash_returns_hashed_key() {
174        assert_eq!(
175            BASE64URL_NOPAD.encode(&key().hash()),
176            "ZlI2kRote1dv7uflTenyIp5jBE0u-7Fl4aIiE0D9L-g"
177        );
178    }
179
180    #[test]
181    fn combine_with_returns_hashed_key() {
182        let combined = key()
183            .combine_with(b"All your base are belong to us!!")
184            .expect("32 byte input");
185        assert_eq!(
186            BASE64URL_NOPAD.encode(&combined),
187            "Jz5asWNCDiVPjIaWKMmTUPtDZihClN8CwdZNMzWODsk"
188        );
189    }
190
191    #[test]
192    fn combine_with_throws_on_incorrect_length() {
193        let cases: [&[u8]; 5] = [
194            b"",
195            b"A",
196            b"All your base are belong to us",
197            b"All your base are belong to us!",
198            b"All your base are belong to us!!!",
199        ];
200        for case in cases {
201            let err = key().combine_with(case).expect_err("wrong length");
202            assert!(
203                err.to_string().contains("hash function"),
204                "unexpected error: {err}"
205            );
206        }
207    }
208}