flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/secrets/src/lib.rs

195 lines6,806 bytesCodeBlame
1//! Secrets at rest, and the signatures put on what crosses between g1t
2//! and outside systems.
3//!
4//! A secret is sealed with AES-256-GCM under its service's own key, with the
5//! id of the row it belongs to as associated data, so a sealed value copied
6//! onto another row does not open.
7
8use aes_gcm::aead::{Aead, KeyInit, Payload};
9use aes_gcm::{Aes256Gcm, Nonce};
10use base64::Engine;
11use base64::engine::general_purpose::STANDARD;
12use hmac::{Hmac, Mac};
13use sha2::{Digest, Sha256};
14
15const VERSION: &str = "v1:";
16
17pub struct Sealer {
18 cipher: Aes256Gcm,
19}
20
21impl Sealer {
22 /// From the service's key: 64 hex characters.
23 pub fn new(key_hex: &str) -> Option<Sealer> {
24 let key = hex::decode(key_hex.trim()).ok()?;
25 (key.len() == 32).then(|| Sealer {
26 cipher: Aes256Gcm::new_from_slice(&key).expect("a 32-byte key"),
27 })
28 }
29
30 pub fn seal(&self, plaintext: &str, bound_to: &str) -> String {
31 let mut nonce = [0u8; 12];
32 getrandom::getrandom(&mut nonce).expect("no source of randomness");
33 let sealed = self
34 .cipher
35 .encrypt(
36 Nonce::from_slice(&nonce),
37 Payload {
38 msg: plaintext.as_bytes(),
39 aad: bound_to.as_bytes(),
40 },
41 )
42 .expect("encrypting cannot fail");
43 let mut out = nonce.to_vec();
44 out.extend(sealed);
45 format!("{VERSION}{}", STANDARD.encode(out))
46 }
47
48 /// Bytes sealed the same way, kept as bytes: the version, the nonce,
49 /// then the ciphertext. For values stored as bytes, such as a cache's.
50 pub fn seal_bytes(&self, plaintext: &[u8], bound_to: &str) -> Vec<u8> {
51 let mut nonce = [0u8; 12];
52 getrandom::getrandom(&mut nonce).expect("no source of randomness");
53 let sealed = self
54 .cipher
55 .encrypt(
56 Nonce::from_slice(&nonce),
57 Payload {
58 msg: plaintext,
59 aad: bound_to.as_bytes(),
60 },
61 )
62 .expect("encrypting cannot fail");
63 let mut out = VERSION.as_bytes().to_vec();
64 out.extend(nonce);
65 out.extend(sealed);
66 out
67 }
68
69 /// What [`Sealer::seal_bytes`] sealed; `None` under another key, for
70 /// another row, or for anything else.
71 pub fn open_bytes(&self, sealed: &[u8], bound_to: &str) -> Option<Vec<u8>> {
72 let bytes = sealed.strip_prefix(VERSION.as_bytes())?;
73 if bytes.len() < 12 {
74 return None;
75 }
76 let (nonce, ciphertext) = bytes.split_at(12);
77 self.cipher
78 .decrypt(
79 Nonce::from_slice(nonce),
80 Payload {
81 msg: ciphertext,
82 aad: bound_to.as_bytes(),
83 },
84 )
85 .ok()
86 }
87
88 /// `None` when it was sealed under another key or for another row.
89 pub fn open(&self, sealed: &str, bound_to: &str) -> Option<String> {
90 let bytes = STANDARD.decode(sealed.strip_prefix(VERSION)?).ok()?;
91 if bytes.len() < 12 {
92 return None;
93 }
94 let (nonce, ciphertext) = bytes.split_at(12);
95 let plain = self
96 .cipher
97 .decrypt(
98 Nonce::from_slice(nonce),
99 Payload {
100 msg: ciphertext,
101 aad: bound_to.as_bytes(),
102 },
103 )
104 .ok()?;
105 String::from_utf8(plain).ok()
106 }
107}
108
109pub fn sha256_hex(value: &str) -> String {
110 sha256_hex_bytes(value.as_bytes())
111}
112
113pub fn sha256_hex_bytes(value: &[u8]) -> String {
114 hex::encode(Sha256::digest(value))
115}
116
117pub fn random_hex(bytes: usize) -> String {
118 let mut buffer = vec![0u8; bytes];
119 getrandom::getrandom(&mut buffer).expect("no source of randomness");
120 hex::encode(buffer)
121}
122
123pub fn hmac_sha256_hex(secret: &str, body: &str) -> String {
124 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret.as_bytes()).expect("any key length");
125 mac.update(body.as_bytes());
126 hex::encode(mac.finalize().into_bytes())
127}
128
129/// Compares in time that does not depend on where they differ.
130pub fn same(a: &str, b: &str) -> bool {
131 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
132}
133
134/// Whether `signature` is `body` signed with `secret`: hex HMAC-SHA256,
135/// optionally written `sha256=<hex>`.
136pub fn signed(secret: &str, body: &str, signature: &str) -> bool {
137 let given = signature.trim();
138 let given = given.strip_prefix("sha256=").unwrap_or(given);
139 same(&hmac_sha256_hex(secret, body), &given.to_ascii_lowercase())
140}
141
142/// The last four characters, to tell keys apart without showing them.
143pub fn hint(secret: &str) -> String {
144 let tail: String = secret.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
145 format!("…{tail}")
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
153
154 #[test]
155 fn a_sealed_secret_opens_only_for_its_own_row() {
156 let sealer = Sealer::new(KEY).unwrap();
157 let sealed = sealer.seal("sk-ant-secret", "con_1");
158 assert!(!sealed.contains("sk-ant"));
159 assert_eq!(sealer.open(&sealed, "con_1").as_deref(), Some("sk-ant-secret"));
160 assert_eq!(sealer.open(&sealed, "con_2"), None);
161 }
162
163 #[test]
164 fn sealed_bytes_open_only_for_their_own_key() {
165 let sealer = Sealer::new(KEY).unwrap();
166 let plain = b"0032HEAD\0symref=HEAD:refs/heads/main\n";
167 let sealed = sealer.seal_bytes(plain, "refs:rep_1:abc");
168 assert!(!sealed.windows(4).any(|window| window == b"HEAD"));
169 assert_eq!(sealer.open_bytes(&sealed, "refs:rep_1:abc").as_deref(), Some(&plain[..]));
170 assert_eq!(sealer.open_bytes(&sealed, "refs:rep_2:abc"), None);
171 let other = Sealer::new(&"ff".repeat(32)).unwrap();
172 assert_eq!(other.open_bytes(&sealed, "refs:rep_1:abc"), None);
173 assert_eq!(sealer.open_bytes(b"v1:short", "refs:rep_1:abc"), None);
174 assert_eq!(sealer.open_bytes(plain, "refs:rep_1:abc"), None);
175 }
176
177 #[test]
178 fn a_key_of_the_wrong_length_is_refused() {
179 assert!(Sealer::new("abcd").is_none());
180 }
181
182 #[test]
183 fn signatures_are_checked_in_either_form() {
184 let signature = hmac_sha256_hex("shh", "{\"a\":1}");
185 assert!(signed("shh", "{\"a\":1}", &signature));
186 assert!(signed("shh", "{\"a\":1}", &format!("sha256={signature}")));
187 assert!(!signed("shh", "{\"a\":2}", &signature));
188 assert!(!signed("other", "{\"a\":1}", &signature));
189 }
190
191 #[test]
192 fn a_hint_shows_only_the_end() {
193 assert_eq!(hint("sk-ant-api03-abcdef"), "…cdef");
194 }
195}