Skip to main content

keyhog_core/
aws.rs

1//! Offline AWS account-ID recovery and canary-token classification.
2//!
3//! This is the **single source of truth** for two credential-string-only facts
4//! about an AWS access-key ID, shared by every keyhog crate (scanner attaches
5//! them as finding metadata with no verify; verifier consults the canary check
6//! to refuse tripping a canary on `--verify`). It lives in `keyhog-core`: the
7//! one crate both `keyhog-scanner` and `keyhog-verifier` depend on, so there is
8//! exactly one decode and one canary list, never a fork.
9//!
10//! 1. **Account decode.** Every modern AWS access-key ID (`AKIA…` long-term,
11//!    `ASIA…` temporary STS) has the 12-digit account number mathematically
12//!    embedded in it, recoverable with a pure base32-decode + bit-shift. NO
13//!    network call, NO STS `GetCallerIdentity`, and it works on LIVE *and*
14//!    revoked keys. Algorithm matches the trufflesecurity write-up
15//!    <https://trufflesecurity.com/blog/research-uncovers-aws-account-numbers-hidden-in-access-keys>:
16//!    drop the 4-char prefix; base32-decode the body; the first 6 decoded bytes
17//!    are a big-endian u48; `account = (u48 & 0x7fff_ffff_ff80) >> 7`, rendered
18//!    as a zero-padded decimal string (12 digits for real AWS keys; see the
19//!    `aws_account_from_key_id` note on crafted 13-digit inputs).
20//!
21//! 2. **Canary classification.** An access key whose decoded account belongs to
22//!    a known canary issuer (canarytokens.org / Thinkst and off-brand clones) is
23//!    a tripwire: any live verification alerts whoever planted it. The baseline
24//!    issuer list is Tier-B data embedded from `data/aws-canary-accounts.toml`.
25//!    CLI config may add account IDs through `.keyhog.toml` `[aws]`
26//!    `canary_accounts` / `knockoff_accounts`; ambient environment is not part
27//!    of this contract. Baseline source:
28//!    <https://trufflesecurity.com/blog/canaries>.
29
30use std::collections::{HashMap, HashSet};
31use std::sync::OnceLock;
32
33/// The two access-key-ID prefixes whose 12-digit account number is embedded.
34/// `AKIA` is a long-term IAM key, `ASIA` a temporary STS session key. Both use
35/// the identical embedding, so both decode with the same routine.
36const AWS_KEY_ID_PREFIXES: [&str; 2] = ["AKIA", "ASIA"];
37
38/// Length of the 4-char access-key-ID prefix (`AKIA`/`ASIA`) that precedes the
39/// base32 body. Single owner for the "skip the prefix" slice offset so the
40/// decode routine can never disagree with [`AWS_KEY_ID_PREFIXES`] about where
41/// the encoded account bits begin.
42const AWS_KEY_ID_PREFIX_LEN: usize = 4;
43
44/// The base32 body of an access-key ID: 16 chars after the prefix, encoding
45/// the 80-bit account+discriminator payload.
46const AWS_KEY_ID_BODY_LEN: usize = 16;
47
48/// Number of leading base32 chars that carry the 48 account bits we need
49/// (10 * 5 = 50 bits; we keep the top 48). Single owner so the decode loop and
50/// the length math cannot disagree.
51const ACCOUNT_BASE32_CHARS: usize = 10;
52
53/// Length of a canonical AWS access-key ID: 4-char prefix + 16 base32 chars.
54const AWS_KEY_ID_LEN: usize = AWS_KEY_ID_PREFIX_LEN + AWS_KEY_ID_BODY_LEN;
55
56/// The 48-bit mask + 7-bit right shift that extracts the account number from
57/// the leading 6 decoded bytes. Documented by trufflesecurity; the low 7 bits
58/// are a non-account discriminator, and bit 47 is always 0 for the account.
59const ACCOUNT_MASK: u64 = 0x7fff_ffff_ff80;
60const ACCOUNT_SHIFT: u64 = 7;
61
62/// Decode an RFC-4648 standard base32 character (`A`-`Z`, `2`-`7`) to its 5-bit
63/// value. Returns `None` for any out-of-alphabet byte (lowercase, padding,
64/// digits 0/1/8/9), which makes the whole decode fail closed on a malformed id.
65#[inline]
66fn base32_value(c: u8) -> Option<u8> {
67    match c {
68        b'A'..=b'Z' => Some(c - b'A'),
69        b'2'..=b'7' => Some(c - b'2' + 26),
70        _ => None,
71    }
72}
73
74/// Recover the 12-digit AWS account ID embedded in an access-key ID, fully
75/// offline. Returns `None` when `key_id` is not a well-formed `AKIA…`/`ASIA…`
76/// access-key ID (wrong length, wrong prefix, or a non-base32 body), so a
77/// caller can blindly try every credential and only act on `Some`.
78///
79/// For a real AWS access-key ID the returned string is 12 ASCII digits,
80/// zero-padded: AWS account numbers are 12-digit identifiers and the
81/// leading-zero form (e.g. `052310077262`) is the canonical rendering, matching
82/// the STS `Account` field and trufflehog's output.
83///
84/// NOTE: the decoded 48-bit account field can arithmetically reach
85/// `1_099_511_627_775` (13 digits) for a *crafted* key-shaped body that no real
86/// AWS key produces; `{:012}` zero-pads but does not truncate, so such
87/// adversarial inputs render 13 digits. Callers must not assume a fixed width
88/// for untrusted input.
89#[must_use]
90pub(crate) fn aws_account_from_key_id(key_id: &str) -> Option<String> {
91    let key_id = key_id.trim();
92    if key_id.len() != AWS_KEY_ID_LEN {
93        return None;
94    }
95    if !AWS_KEY_ID_PREFIXES
96        .iter()
97        .any(|p| key_id.as_bytes().starts_with(p.as_bytes()))
98    {
99        return None;
100    }
101
102    // The 16 base32 chars after the prefix encode 80 bits; we only need the
103    // leading 48 bits (first 6 bytes), which come from the first 10 base32
104    // chars (10 * 5 = 50 bits). Accumulate those 50 bits, then keep the top 48.
105    let body = &key_id.as_bytes()[AWS_KEY_ID_PREFIX_LEN..];
106    let mut acc: u64 = 0;
107    for &c in &body[..ACCOUNT_BASE32_CHARS] {
108        let v = base32_value(c)?;
109        acc = (acc << 5) | u64::from(v);
110    }
111    // `acc` now holds 50 bits (the first 10 chars). The leading 48 bits are the
112    // u48 we want, so drop the low 2 bits.
113    let u48 = acc >> 2;
114    let account = (u48 & ACCOUNT_MASK) >> ACCOUNT_SHIFT;
115    Some(format!("{account:012}"))
116}
117
118/// The Tier-B baseline canary account list, compiled into the binary from
119/// `data/aws-canary-accounts.toml`.
120static BASE_CANARY_ACCOUNTS: std::sync::LazyLock<Result<HashSet<String>, String>> =
121    std::sync::LazyLock::new(load_canary_accounts);
122
123static EXTRA_CANARY_ACCOUNTS: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();
124
125fn extra_canary_accounts() -> &'static std::sync::RwLock<HashSet<String>> {
126    EXTRA_CANARY_ACCOUNTS.get_or_init(|| std::sync::RwLock::new(HashSet::new()))
127}
128
129fn load_canary_accounts() -> Result<HashSet<String>, String> {
130    let set = parse_canary_accounts(include_str!("../data/aws-canary-accounts.toml")).map_err(
131        |error| {
132            format!(
133                "crates/core/data/aws-canary-accounts.toml is invalid: {error}. \
134                 Fix the bundled Tier-B canary list; refusing to run without canary awareness."
135            )
136        },
137    )?;
138    if set.is_empty() {
139        return Err(
140            "crates/core/data/aws-canary-accounts.toml is invalid: no canary accounts. \
141             Fix the bundled Tier-B canary list; refusing to run without canary awareness."
142                .to_string(),
143        );
144    }
145    Ok(set)
146}
147
148/// Validate the compiled Tier-B canary account baseline.
149pub fn validate_canary_accounts() -> Result<(), String> {
150    base_canary_accounts().map(|_| ())
151}
152
153fn base_canary_accounts() -> Result<&'static HashSet<String>, String> {
154    match BASE_CANARY_ACCOUNTS.as_ref() {
155        Ok(accounts) => Ok(accounts),
156        Err(error) => Err(error.clone()),
157    }
158}
159
160/// `[canary]`/`[knockoff]` TOML shape used by the embedded baseline. Both
161/// tables are merged into the same account set, keyhog treats off-brand
162/// knockoffs identically to first-party canaries.
163#[derive(serde::Deserialize, Default)]
164struct CanaryFile {
165    #[serde(default)]
166    canary: CanaryTable,
167    #[serde(default)]
168    knockoff: CanaryTable,
169}
170
171#[derive(serde::Deserialize, Default)]
172struct CanaryTable {
173    #[serde(default)]
174    accounts: Vec<String>,
175}
176
177/// Validate one raw canary account string and insert the trimmed 12-digit id
178/// into `set`. Single owner for the "trim + reject empty + require 12 ASCII
179/// digits" contract shared by the embedded baseline parser and the
180/// `.keyhog.toml` config parser, so the two can never disagree on what a valid
181/// AWS account id is.
182fn insert_validated_account(set: &mut HashSet<String>, raw_account: &str) -> Result<(), String> {
183    let account = raw_account.trim();
184    if account.is_empty() {
185        return Err("canary account entries must not be empty".to_string());
186    }
187    if account.len() != 12 || !account.bytes().all(|byte| byte.is_ascii_digit()) {
188        return Err(format!(
189            "canary account {account:?} must be a 12-digit AWS account id"
190        ));
191    }
192    set.insert(account.to_string());
193    Ok(())
194}
195
196/// Parse one canary TOML document. Trims each account so whitespace in a
197/// hand-edited data file never silently misses.
198pub(crate) fn parse_canary_accounts(raw: &str) -> Result<HashSet<String>, String> {
199    let parsed: CanaryFile = toml::from_str(raw)
200        .map_err(|error| format!("invalid aws-canary-accounts.toml: {error}"))?;
201    let mut set = HashSet::new();
202    for raw_account in parsed
203        .canary
204        .accounts
205        .into_iter()
206        .chain(parsed.knockoff.accounts)
207    {
208        insert_validated_account(&mut set, &raw_account)?;
209    }
210    Ok(set)
211}
212
213/// Parse and validate raw 12-digit account IDs from `.keyhog.toml` config.
214pub fn parse_canary_account_ids<I, S>(accounts: I) -> Result<HashSet<String>, String>
215where
216    I: IntoIterator<Item = S>,
217    S: AsRef<str>,
218{
219    let mut set = HashSet::new();
220    for raw_account in accounts {
221        insert_validated_account(&mut set, raw_account.as_ref())?;
222    }
223    Ok(set)
224}
225
226/// Replace the process-local extra canary account set supplied by `.keyhog.toml`.
227///
228/// Passing an empty set clears extras. The embedded baseline is always active.
229pub fn set_extra_canary_accounts(accounts: HashSet<String>) {
230    let mut guard = match extra_canary_accounts().write() {
231        Ok(guard) => guard,
232        Err(poisoned) => {
233            // LAW10: fail-closed/security: a poisoned process-local
234            // canary-extension lock still owns a valid HashSet; recovering
235            // preserves explicit canary config and the embedded baseline
236            // remains active.
237            poisoned.into_inner()
238        }
239    };
240    *guard = accounts;
241}
242
243fn account_in_extra_canaries(account_id: &str) -> bool {
244    match extra_canary_accounts().read() {
245        Ok(accounts) => accounts.contains(account_id),
246        Err(poisoned) => {
247            // LAW10: fail-closed/security: a poisoned process-local
248            // canary-extension lock still owns a valid HashSet; recovering
249            // preserves explicit canary config and the embedded baseline
250            // remains active.
251            poisoned.into_inner().contains(account_id)
252        }
253    }
254}
255
256/// True when `account_id` (a 12-digit AWS account string) belongs to a known
257/// canary-token issuer.
258#[must_use]
259pub(crate) fn account_is_canary(account_id: &str) -> bool {
260    base_canary_accounts().is_ok_and(|accounts| accounts.contains(account_id))
261        || account_in_extra_canaries(account_id)
262}
263
264fn account_is_canary_checked(account_id: &str) -> Result<bool, String> {
265    Ok(base_canary_accounts()?.contains(account_id) || account_in_extra_canaries(account_id))
266}
267
268/// Checked canary classifier used by live-verification paths.
269///
270/// This surfaces a corrupt embedded baseline as an error so callers can fail
271/// closed before any network verification.
272pub fn key_id_canary_status(key_id: &str) -> Result<bool, String> {
273    match aws_account_from_key_id(key_id) {
274        Some(account_id) => account_is_canary_checked(&account_id),
275        None => Ok(false),
276    }
277}
278
279/// Operator-facing note attached to a canary finding so the report explains why
280/// verification was skipped. Mirrors trufflehog's responder message.
281pub(crate) const CANARY_MESSAGE: &str =
282    "AWS canary token (canarytokens.org / Thinkst-style). Do NOT verify: a \
283     verification request alerts whoever planted it. See \
284     https://trufflesecurity.com/canaries";
285
286/// Build the offline metadata for an AWS-access-key finding: always
287/// `{ "account_id": "<12 digits>" }` for a decodable `AKIA…`/`ASIA…` key, plus
288/// `{ "is_canary": "true", "canary_message": <note> }` when the decoded account
289/// belongs to a known canary issuer. `None` when `credential` is not a
290/// well-formed AWS access-key ID.
291///
292/// The `HashMap<String, String>` shape lets a [`crate::VerifiedFinding`]'s
293/// `metadata` absorb it directly, with no verify and no network.
294#[must_use]
295pub fn finding_metadata(credential: &str) -> Option<HashMap<String, String>> {
296    let account_id = aws_account_from_key_id(credential)?;
297    let is_canary = account_is_canary(&account_id);
298    let mut meta = HashMap::new();
299    meta.insert("account_id".to_string(), account_id);
300    if is_canary {
301        meta.insert("is_canary".to_string(), "true".to_string());
302        meta.insert("canary_message".to_string(), CANARY_MESSAGE.to_string());
303    }
304    Some(meta)
305}