1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//! Regression: the ONE real base32 (RFC-4648) decoder keyhog ships.
//!
//! keyhog's decode *pipeline* (base64/hex/url/…/z85/reverse/caesar) has NO
//! base32 stage, that absence is pinned by `regression_base32_decode.rs`. But
//! keyhog DOES contain a genuine RFC-4648 standard base32 decoder in
//! `keyhog-core::aws` (`aws_account_from_key_id`), the routine that handles the
//! only base32-shaped credential keyhog cares about: the 16-char base32 body of
//! an `AKIA…`/`ASIA…` AWS access-key ID, from which the 12-digit owning account
//! number is recovered fully offline (trufflesecurity algorithm). This file
//! pins that decoder's exact decoded values, alphabet handling, fail-closed
//! refusals, and the canary-metadata contract layered on top of it, through the
//! crate's PUBLIC surface (`finding_metadata`, `key_id_canary_status`,
//! `parse_canary_account_ids`, `set_extra_canary_accounts`,
//! `validate_canary_accounts`).
//!
//! All decode vectors were computed independently from the documented algorithm
//! (drop 4-char prefix; base32-decode; first 6 bytes = big-endian u48;
//! `account = (u48 & 0x7fff_ffff_ff80) >> 7`, zero-padded). Nothing here touches
//! an accelerator; the decode is pure arithmetic and host-independent.
#![cfg(feature = "decode")]
use std::collections::HashSet;
use keyhog_core::{
finding_metadata, key_id_canary_status, parse_canary_account_ids, set_extra_canary_accounts,
validate_canary_accounts,
};
/// Fetch the decoded `account_id` string from a credential's finding metadata,
/// or `None` when the credential is not a well-formed AWS access-key ID.
fn account_of(credential: &str) -> Option<String> {
finding_metadata(credential).map(|meta| {
meta.get("account_id")
.expect("a decodable AWS key id always attaches account_id metadata")
.clone()
})
}
// ---- positive: exact decoded base32 vectors -------------------------------
#[test]
fn known_vector_asia_decodes_exact_account() {
// ASIAY34FZKBOKMUTVV7A: base32 body Y34FZKBOKM… → account 609629065308.
assert_eq!(
account_of("ASIAY34FZKBOKMUTVV7A").as_deref(),
Some("609629065308"),
"canonical ASIA vector must base32-decode to 609629065308"
);
}
#[test]
fn akia_and_asia_same_body_decode_identically() {
// Both prefixes use the identical 16-char base32 embedding, so an identical
// body must decode to the identical account regardless of AKIA vs ASIA.
let akia = account_of("AKIAY34FZKBOKMUTVV7A");
let asia = account_of("ASIAY34FZKBOKMUTVV7A");
assert_eq!(akia.as_deref(), Some("609629065308"));
assert_eq!(asia.as_deref(), Some("609629065308"));
assert_eq!(akia, asia, "prefix must not change the decoded account");
}
#[test]
fn all_a_body_decodes_to_zero_account() {
// Base32 'A' == 5-bit value 0, so an all-'A' body is the arithmetic minimum:
// account 0, rendered zero-padded to 12 digits.
assert_eq!(
account_of("AKIAAAAAAAAAAAAAAAAA").as_deref(),
Some("000000000000"),
"all-A base32 body is the zero account, zero-padded to 12 digits"
);
}
#[test]
fn all_sevens_body_yields_thirteen_digit_account_at_boundary() {
// Base32 '7' == value 31 (top of the 2..=7 range). An all-'7' body drives the
// masked u48 to its maximum 0xFFFFFFFFFF after the >>7, i.e. 1099511627775
// which is THIRTEEN decimal digits, not twelve. This pins the true arithmetic
// upper bound of the decoder and shows the `{:012}` rendering does not (and
// cannot) truncate an over-max value.
let account =
account_of("AKIA7777777777777777").expect("valid base32 body must decode to Some");
assert_eq!(account, "1099511627775", "all-7 body is the arithmetic max");
assert_eq!(
account.len(),
13,
"the maximum decodable account overflows the 12-digit contract to 13 digits"
);
}
#[test]
fn second_alphabet_range_digit_body_decodes_exact() {
// '2' is the first char of the base32 digit range (value 26). An all-'2' body
// exercises that range specifically and decodes to a fixed known account.
assert_eq!(
account_of("AKIA2222222222222222").as_deref(),
Some("744830457525"),
"all-'2' base32 body decodes to 744830457525"
);
}
#[test]
fn surrounding_whitespace_is_trimmed_before_decode() {
// The decoder trims before the length/prefix checks, so leading/trailing
// whitespace around an otherwise-canonical key still decodes.
assert_eq!(
account_of(" \tASIAY34FZKBOKMUTVV7A\n").as_deref(),
Some("609629065308"),
"whitespace around the key id must be trimmed, then decoded"
);
}
// ---- negative twins: fail-closed refusals ---------------------------------
#[test]
fn non_base32_digit_in_leading_body_fails_closed() {
// '0' is OUTSIDE the RFC-4648 base32 alphabet (which omits 0/1/8/9). Placed in
// the first 10 body chars (the bytes actually decoded), it must fail the whole
// decode CLOSED → no metadata at all, not a wrong account.
assert_eq!(
finding_metadata("AKIA034FZKBOKMUTVV7A"),
None,
"a non-base32 digit in the decoded body must fail closed"
);
}
#[test]
fn lowercase_body_fails_closed() {
// RFC-4648 base32 is upper-case only; lowercase is out of alphabet. The
// decoder must NOT case-fold (a lowercased body fails closed).
assert_eq!(
finding_metadata("AKIAy34fzkbokmutvv7a"),
None,
"lowercase base32 body is out-of-alphabet and must fail closed"
);
}
#[test]
fn wrong_prefix_fails_closed() {
// Only AKIA/ASIA carry the embedded account. A well-formed base32 body under
// any other 4-char prefix must decode to nothing.
assert_eq!(
finding_metadata("AAAAY34FZKBOKMUTVV7A"),
None,
"non-AKIA/ASIA prefix must fail closed"
);
assert_eq!(
finding_metadata("AKIBY34FZKBOKMUTVV7A"),
None,
"near-miss prefix (AKIB) must fail closed"
);
}
#[test]
fn wrong_length_fails_closed_both_directions() {
// Canonical length is exactly 20 (4 prefix + 16 body). One char short or one
// char long both fail closed (the length gate is exact, not a minimum).
assert_eq!(
finding_metadata("AKIAY34FZKBOKMUTVV7"),
None,
"19-char (one short) must fail closed"
);
assert_eq!(
finding_metadata("AKIAY34FZKBOKMUTVV7AA"),
None,
"21-char (one long) must fail closed"
);
}
#[test]
fn invalid_char_in_trailing_body_is_not_validated() {
// Only the FIRST 10 base32 chars (the leading 48 account bits) are decoded and
// validated; the trailing 6 chars are never read. So an out-of-alphabet byte
// in the last position ('0') does NOT fail the decode, the same account still
// comes back. This pins the decoder's partial-validation contract precisely so
// any future full-body validation change is caught.
assert_eq!(
account_of("AKIAY34FZKBOKMUTVV70").as_deref(),
Some("609629065308"),
"an out-of-alphabet byte beyond the first 10 body chars is not validated"
);
}
// ---- canary metadata layered on the decode --------------------------------
#[test]
fn non_canary_key_metadata_is_account_id_only() {
// A decodable, non-canary key attaches EXACTLY one metadata entry: account_id.
// No is_canary / canary_message keys appear.
let meta = finding_metadata("AKIA2222222222222222").expect("valid key yields metadata");
assert_eq!(
meta.get("account_id").map(String::as_str),
Some("744830457525")
);
assert_eq!(meta.len(), 1, "non-canary metadata is account_id only");
assert!(
!meta.contains_key("is_canary"),
"non-canary key must not carry is_canary"
);
assert_eq!(
key_id_canary_status("AKIA2222222222222222"),
Ok(false),
"a non-baseline account is not a canary"
);
}
#[test]
fn non_key_string_is_not_a_canary_and_has_no_metadata() {
// A string that is not an AWS key id: no metadata, and the checked canary
// status is Ok(false) (the None decode path), never an error.
assert_eq!(finding_metadata("this is not a key"), None);
assert_eq!(
key_id_canary_status("this is not a key"),
Ok(false),
"an undecodable string is not a canary and must not error"
);
}
#[test]
fn extra_canary_account_marks_metadata_and_status() {
// AKIAIOSFODNN7EXAMPLE decodes to account 581039954779 (not in the baseline).
// Registering it as an extra canary must flip both finding_metadata and the
// checked status. This account is used ONLY in this test to avoid racing the
// process-global extra set with the non-canary assertions above.
assert_eq!(
account_of("AKIAIOSFODNN7EXAMPLE").as_deref(),
Some("581039954779"),
"example key decodes to 581039954779"
);
let mut extras = HashSet::new();
extras.insert("581039954779".to_string());
set_extra_canary_accounts(extras);
let meta = finding_metadata("AKIAIOSFODNN7EXAMPLE").expect("valid key yields metadata");
assert_eq!(
meta.get("account_id").map(String::as_str),
Some("581039954779")
);
assert_eq!(
meta.get("is_canary").map(String::as_str),
Some("true"),
"an extra-registered canary account must set is_canary=true"
);
let message = meta
.get("canary_message")
.expect("a canary finding must carry an operator note");
assert!(
message.contains("Do NOT verify"),
"canary note must warn against verification; got {message:?}"
);
assert!(
message.contains("canarytokens.org"),
"canary note must name the issuer; got {message:?}"
);
assert_eq!(
key_id_canary_status("AKIAIOSFODNN7EXAMPLE"),
Ok(true),
"checked status must agree with the metadata"
);
// Restore process-global state so no other test observes the extra account.
set_extra_canary_accounts(HashSet::new());
assert_eq!(
key_id_canary_status("AKIAIOSFODNN7EXAMPLE"),
Ok(false),
"clearing extras must un-mark the account"
);
}
// ---- config validation surface --------------------------------------------
#[test]
fn parse_canary_account_ids_accepts_valid_rejects_malformed() {
// A valid 12-digit account passes and lands in the set.
let ok = parse_canary_account_ids(["052310077262"]).expect("12-digit id is valid");
assert_eq!(ok.len(), 1);
assert!(ok.contains("052310077262"), "the account must be retained");
// 11 digits (one short) is rejected with a message naming the 12-digit rule.
let short = parse_canary_account_ids(["05231007726"]).unwrap_err();
assert!(
short.contains("12-digit"),
"short id error must cite the 12-digit rule; got {short:?}"
);
// A non-digit char is rejected the same way.
let nondigit = parse_canary_account_ids(["05231007726X"]).unwrap_err();
assert!(
nondigit.contains("12-digit"),
"non-digit id error must cite the 12-digit rule; got {nondigit:?}"
);
// An empty entry is rejected with the empty-specific message.
let empty = parse_canary_account_ids([" "]).unwrap_err();
assert!(
empty.contains("must not be empty"),
"blank id must be rejected as empty; got {empty:?}"
);
}
#[test]
fn baseline_canary_accounts_validate_ok() {
// The embedded Tier-B canary baseline must parse into a non-corrupt set
// validate_canary_accounts returns Ok(()) on the shipped data file.
assert_eq!(
validate_canary_accounts(),
Ok(()),
"shipped aws-canary-accounts.toml must validate"
);
}
// ---- decode-pipeline linkage ----------------------------------------------
#[cfg(feature = "decode")]
#[test]
fn base32_absent_from_pipeline_but_aws_base32_body_is_handled() {
// The decode PIPELINE has no base32 stage (that family stays {base64,hex,z85})…
let names = keyhog_scanner::testing::default_decoder_names_for_test();
assert!(
!names.iter().any(|n| *n == "base32"),
"no `base32` decoder may exist in the default pipeline; got {names:?}"
);
// …yet the one base32-shaped credential keyhog handles (an AWS key body) IS
// decoded, by the dedicated AWS routine, not the generic pipeline. Concrete
// proof: the same known vector still yields its exact account.
assert_eq!(
account_of("ASIAY34FZKBOKMUTVV7A").as_deref(),
Some("609629065308"),
"base32-shaped AWS body must be decoded by the AWS path despite no pipeline base32 decoder"
);
}