pr_01m47d24b0e6n91zwymwxg0vpx/services/integrations/src/crypto.rs

137 lines4,562 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Integrations: your own model provider, alerts that open issues, tickets agents read1//! Secrets at rest, and the signatures outside systems put on what they
2//! send.
3//!
4//! A connection's secrets are sealed with AES-256-GCM under the service's
5//! own key, with the connection's id as associated data, so a sealed value
6//! copied 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 /// `None` when it was sealed under another key or for another row.
49 pub fn open(&self, sealed: &str, bound_to: &str) -> Option<String> {
50 let bytes = STANDARD.decode(sealed.strip_prefix(VERSION)?).ok()?;
51 if bytes.len() < 12 {
52 return None;
53 }
54 let (nonce, ciphertext) = bytes.split_at(12);
55 let plain = self
56 .cipher
57 .decrypt(
58 Nonce::from_slice(nonce),
59 Payload {
60 msg: ciphertext,
61 aad: bound_to.as_bytes(),
62 },
63 )
64 .ok()?;
65 String::from_utf8(plain).ok()
66 }
67}
68
69pub fn sha256_hex(value: &str) -> String {
70 hex::encode(Sha256::digest(value.as_bytes()))
71}
72
73pub fn random_hex(bytes: usize) -> String {
74 let mut buffer = vec![0u8; bytes];
75 getrandom::getrandom(&mut buffer).expect("no source of randomness");
76 hex::encode(buffer)
77}
78
79pub fn hmac_sha256_hex(secret: &str, body: &str) -> String {
80 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret.as_bytes()).expect("any key length");
81 mac.update(body.as_bytes());
82 hex::encode(mac.finalize().into_bytes())
83}
84
85/// Compares in time that does not depend on where they differ.
86pub fn same(a: &str, b: &str) -> bool {
87 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
88}
89
90/// Whether `signature` is `body` signed with `secret`: hex HMAC-SHA256,
91/// optionally written `sha256=<hex>`.
92pub fn signed(secret: &str, body: &str, signature: &str) -> bool {
93 let given = signature.trim();
94 let given = given.strip_prefix("sha256=").unwrap_or(given);
95 same(&hmac_sha256_hex(secret, body), &given.to_ascii_lowercase())
96}
97
98/// The last four characters, to tell keys apart without showing them.
99pub fn hint(secret: &str) -> String {
100 let tail: String = secret.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
101 format!("…{tail}")
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
109
110 #[test]
111 fn a_sealed_secret_opens_only_for_its_own_row() {
112 let sealer = Sealer::new(KEY).unwrap();
113 let sealed = sealer.seal("sk-ant-secret", "con_1");
114 assert!(!sealed.contains("sk-ant"));
115 assert_eq!(sealer.open(&sealed, "con_1").as_deref(), Some("sk-ant-secret"));
116 assert_eq!(sealer.open(&sealed, "con_2"), None);
117 }
118
119 #[test]
120 fn a_key_of_the_wrong_length_is_refused() {
121 assert!(Sealer::new("abcd").is_none());
122 }
123
124 #[test]
125 fn signatures_are_checked_in_either_form() {
126 let signature = hmac_sha256_hex("shh", "{\"a\":1}");
127 assert!(signed("shh", "{\"a\":1}", &signature));
128 assert!(signed("shh", "{\"a\":1}", &format!("sha256={signature}")));
129 assert!(!signed("shh", "{\"a\":2}", &signature));
130 assert!(!signed("other", "{\"a\":1}", &signature));
131 }
132
133 #[test]
134 fn a_hint_shows_only_the_end() {
135 assert_eq!(hint("sk-ant-api03-abcdef"), "…cdef");
136 }
137}