use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
const AWS_KEY_ID_PREFIXES: [&str; 2] = ["AKIA", "ASIA"];
const AWS_KEY_ID_PREFIX_LEN: usize = 4;
const AWS_KEY_ID_BODY_LEN: usize = 16;
const ACCOUNT_BASE32_CHARS: usize = 10;
const AWS_KEY_ID_LEN: usize = AWS_KEY_ID_PREFIX_LEN + AWS_KEY_ID_BODY_LEN;
const ACCOUNT_MASK: u64 = 0x7fff_ffff_ff80;
const ACCOUNT_SHIFT: u64 = 7;
#[inline]
fn base32_value(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'2'..=b'7' => Some(c - b'2' + 26),
_ => None,
}
}
#[must_use]
pub(crate) fn aws_account_from_key_id(key_id: &str) -> Option<String> {
let key_id = key_id.trim();
if key_id.len() != AWS_KEY_ID_LEN {
return None;
}
if !AWS_KEY_ID_PREFIXES
.iter()
.any(|p| key_id.as_bytes().starts_with(p.as_bytes()))
{
return None;
}
let body = &key_id.as_bytes()[AWS_KEY_ID_PREFIX_LEN..];
let mut acc: u64 = 0;
for &c in &body[..ACCOUNT_BASE32_CHARS] {
let v = base32_value(c)?;
acc = (acc << 5) | u64::from(v);
}
let u48 = acc >> 2;
let account = (u48 & ACCOUNT_MASK) >> ACCOUNT_SHIFT;
Some(format!("{account:012}"))
}
static BASE_CANARY_ACCOUNTS: std::sync::LazyLock<Result<HashSet<String>, String>> =
std::sync::LazyLock::new(load_canary_accounts);
static EXTRA_CANARY_ACCOUNTS: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();
fn extra_canary_accounts() -> &'static std::sync::RwLock<HashSet<String>> {
EXTRA_CANARY_ACCOUNTS.get_or_init(|| std::sync::RwLock::new(HashSet::new()))
}
fn load_canary_accounts() -> Result<HashSet<String>, String> {
let set = parse_canary_accounts(include_str!("../data/aws-canary-accounts.toml")).map_err(
|error| {
format!(
"crates/core/data/aws-canary-accounts.toml is invalid: {error}. \
Fix the bundled Tier-B canary list; refusing to run without canary awareness."
)
},
)?;
if set.is_empty() {
return Err(
"crates/core/data/aws-canary-accounts.toml is invalid: no canary accounts. \
Fix the bundled Tier-B canary list; refusing to run without canary awareness."
.to_string(),
);
}
Ok(set)
}
pub fn validate_canary_accounts() -> Result<(), String> {
base_canary_accounts().map(|_| ())
}
fn base_canary_accounts() -> Result<&'static HashSet<String>, String> {
match BASE_CANARY_ACCOUNTS.as_ref() {
Ok(accounts) => Ok(accounts),
Err(error) => Err(error.clone()),
}
}
#[derive(serde::Deserialize, Default)]
struct CanaryFile {
#[serde(default)]
canary: CanaryTable,
#[serde(default)]
knockoff: CanaryTable,
}
#[derive(serde::Deserialize, Default)]
struct CanaryTable {
#[serde(default)]
accounts: Vec<String>,
}
fn insert_validated_account(set: &mut HashSet<String>, raw_account: &str) -> Result<(), String> {
let account = raw_account.trim();
if account.is_empty() {
return Err("canary account entries must not be empty".to_string());
}
if account.len() != 12 || !account.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!(
"canary account {account:?} must be a 12-digit AWS account id"
));
}
set.insert(account.to_string());
Ok(())
}
pub(crate) fn parse_canary_accounts(raw: &str) -> Result<HashSet<String>, String> {
let parsed: CanaryFile = toml::from_str(raw)
.map_err(|error| format!("invalid aws-canary-accounts.toml: {error}"))?;
let mut set = HashSet::new();
for raw_account in parsed
.canary
.accounts
.into_iter()
.chain(parsed.knockoff.accounts)
{
insert_validated_account(&mut set, &raw_account)?;
}
Ok(set)
}
pub fn parse_canary_account_ids<I, S>(accounts: I) -> Result<HashSet<String>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut set = HashSet::new();
for raw_account in accounts {
insert_validated_account(&mut set, raw_account.as_ref())?;
}
Ok(set)
}
pub fn set_extra_canary_accounts(accounts: HashSet<String>) {
let mut guard = match extra_canary_accounts().write() {
Ok(guard) => guard,
Err(poisoned) => {
poisoned.into_inner()
}
};
*guard = accounts;
}
fn account_in_extra_canaries(account_id: &str) -> bool {
match extra_canary_accounts().read() {
Ok(accounts) => accounts.contains(account_id),
Err(poisoned) => {
poisoned.into_inner().contains(account_id)
}
}
}
#[must_use]
pub(crate) fn account_is_canary(account_id: &str) -> bool {
base_canary_accounts().is_ok_and(|accounts| accounts.contains(account_id))
|| account_in_extra_canaries(account_id)
}
fn account_is_canary_checked(account_id: &str) -> Result<bool, String> {
Ok(base_canary_accounts()?.contains(account_id) || account_in_extra_canaries(account_id))
}
pub fn key_id_canary_status(key_id: &str) -> Result<bool, String> {
match aws_account_from_key_id(key_id) {
Some(account_id) => account_is_canary_checked(&account_id),
None => Ok(false),
}
}
pub(crate) const CANARY_MESSAGE: &str =
"AWS canary token (canarytokens.org / Thinkst-style). Do NOT verify: a \
verification request alerts whoever planted it. See \
https://trufflesecurity.com/canaries";
#[must_use]
pub fn finding_metadata(credential: &str) -> Option<HashMap<String, String>> {
let account_id = aws_account_from_key_id(credential)?;
let is_canary = account_is_canary(&account_id);
let mut meta = HashMap::new();
meta.insert("account_id".to_string(), account_id);
if is_canary {
meta.insert("is_canary".to_string(), "true".to_string());
meta.insert("canary_message".to_string(), CANARY_MESSAGE.to_string());
}
Some(meta)
}