microvms_core/control/token.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Idempotency tokens, unique per attempt by construction (TRAP-1).
3//!
4//! # What a `clientToken` actually is
5//!
6//! A **permanent** idempotency key, not a request deduplication window. Measured
7//! 2026-08-02, the expensive way: delete an image, recreate it from the same bytes
8//! under the same name with a token derived from that identity, and the service
9//! replays the original create as a no-op. The image then sits in `CREATING` with its
10//! builds never scheduled, cannot be deleted (`CREATING` forbids it), and its only
11//! version cannot be dropped either (it is the last one). Two images were wedged that
12//! way for roughly fifteen hours.
13//!
14//! # Why there is no way to pass one
15//!
16//! The previous Python shape defaulted correctly and *accepted* `client_token=<content
17//! digest>` — which is precisely the value that wedges an image, offered as the
18//! natural thing to pass. So the closure here is the absence of the parameter:
19//! [`create_token`] and [`run_token`] take a **scope label** and mint the token
20//! themselves. A label lands *next to* the nonce and can never replace it.
21//!
22//! That is the difference between a default a caller can override and a mistake a
23//! caller cannot write. `grep` for `client_token` across this module's public surface
24//! finds nothing, and the test at the bottom of this file asserts the label cannot
25//! reach the nonce's position no matter what is passed.
26//!
27//! # The shape, and which end gets truncated
28//!
29//! `<verb>-<label>-<16 hex>`, with `verb` one of `create` or `run` and the nonce eight
30//! bytes of fresh randomness rendered as sixteen hex characters.
31//!
32//! The label is truncated to its **tail**, not its head: `run`'s scope defaults to the
33//! image identifier, which is a full ARN, and an ARN's distinguishing part is the
34//! resource name at the end while every ARN in a region shares its prefix. Truncating
35//! the head would collapse two different images onto one label.
36//!
37//! The nonce is **never** truncated. That ordering is the whole property: a shortened
38//! label cannot make two attempts collide, because collision-freedom lives entirely in
39//! the sixteen hex characters and the truncation happens before them. Found 2026-08-07
40//! by the drift checker's coverage report naming `RunMicrovmRequestClientTokenString`
41//! as unbound — an ap-northeast-1 ARN carrying a legal 64-character image name mints a
42//! 142-character token, over the 128 ceiling, and botocore does not check `max`, so it
43//! would have gone to the wire and failed a launch on a field the caller never set.
44
45use std::fmt::Write as _;
46
47use crate::constants::MAX_CLIENT_TOKEN_LEN;
48
49/// Bytes of randomness folded into every token: eight, so two attempts one second
50/// apart do not collide even under a retry storm.
51///
52/// Rendered as [`TOKEN_NONCE_HEX_LEN`] hex characters. Both constants exist because
53/// the *hex* length is what the ceiling arithmetic needs and deriving it in three
54/// places is three places to get the factor of two wrong.
55const TOKEN_NONCE_BYTES: usize = 8;
56
57/// The nonce's rendered width, `TOKEN_NONCE_BYTES * 2`.
58///
59/// Test-only, and deliberately so: production code renders the nonce by iterating the bytes,
60/// so a second constant it *used* would be a second thing to keep in step. The tests below
61/// need it to check the ceiling arithmetic — and they compare it against
62/// `TOKEN_NONCE_BYTES * 2` rather than trusting it, so a change to either is caught.
63#[cfg(test)]
64const TOKEN_NONCE_HEX_LEN: usize = TOKEN_NONCE_BYTES * 2;
65
66/// How much of the scope label survives into a token.
67///
68/// Not a cosmetic cap — see the module docs for the 142-character token that motivated
69/// it. Sized so that the worst legal scope still fits [`MAX_CLIENT_TOKEN_LEN`]: the
70/// test at the bottom of this file asserts the arithmetic rather than trusting it.
71const MAX_TOKEN_SCOPE_LEN: usize = 64;
72
73/// The verb prefix, closed over the two operations that carry an idempotency token on
74/// this client's paths.
75///
76/// An enum rather than a `&str` parameter because a third verb is a new operation and
77/// should be a change to this file, not a string a call site invents. `UpdateMicrovmImage`
78/// is the third `idempotencyToken: true` member in the model and is deliberately absent:
79/// this client never calls it.
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81enum Verb {
82 Create,
83 Run,
84}
85
86impl Verb {
87 fn as_str(self) -> &'static str {
88 match self {
89 Verb::Create => "create",
90 Verb::Run => "run",
91 }
92 }
93}
94
95/// An image-create idempotency token, unique per attempt.
96///
97/// `scope` is a **label only** — a readability aid for whoever reads CloudTrail, folded
98/// in beside the nonce. It cannot become the token, and there is no parameter that can:
99/// see the module docs for the fifteen-hour wedge that closure exists to prevent
100/// (`docs/PLATFORM.md`, "`clientToken` is a permanent idempotency key").
101pub fn create_token(scope: &str) -> String {
102 mint(Verb::Create, scope, &nonce_bytes())
103}
104
105/// A run idempotency token, unique per attempt. Same rule as [`create_token`].
106///
107/// Cheaper to get wrong than the image case — a replayed run returns the original
108/// MicroVM rather than wedging anything — but the failure is worse to read: a caller
109/// who asked for a second VM gets the first one's id back, and two callers then drive
110/// the same guest.
111pub fn run_token(scope: &str) -> String {
112 mint(Verb::Run, scope, &nonce_bytes())
113}
114
115/// The one place a token is assembled, with the randomness passed in.
116///
117/// Split from the two public functions so a test can drive the *format* with a fixed
118/// nonce and still exercise the real assembly, rather than reimplementing the format in
119/// the test and asserting a copy of it against itself.
120fn mint(verb: Verb, scope: &str, nonce: &[u8]) -> String {
121 // The tail, and on a character boundary: `scope` is arbitrary caller text, so
122 // slicing by byte index would panic on a multi-byte character straddling the cut.
123 // `char_indices` finds the first boundary at or after the cut point, which keeps at
124 // most MAX_TOKEN_SCOPE_LEN bytes — never more, which is what the ceiling needs.
125 let label = tail(scope, MAX_TOKEN_SCOPE_LEN);
126
127 let mut token = String::with_capacity(verb.as_str().len() + 2 + label.len() + nonce.len() * 2);
128 token.push_str(verb.as_str());
129 token.push('-');
130 token.push_str(label);
131 token.push('-');
132 for byte in nonce {
133 // Two lowercase hex digits per byte, so the rendered width is exactly
134 // 2 * nonce.len() and the ceiling arithmetic below holds.
135 let _ = write!(token, "{byte:02x}");
136 }
137
138 debug_assert!(
139 token.len() <= MAX_CLIENT_TOKEN_LEN,
140 "minted a {}-character token, over the {MAX_CLIENT_TOKEN_LEN} ceiling: {token}",
141 token.len()
142 );
143 token
144}
145
146/// The last `max_bytes` bytes of `text`, cut at a character boundary.
147///
148/// Returns a possibly-shorter slice when the boundary falls inside a multi-byte
149/// character, which is the safe direction: the ceiling is an upper bound.
150fn tail(text: &str, max_bytes: usize) -> &str {
151 if text.len() <= max_bytes {
152 return text;
153 }
154 let cut = text.len() - max_bytes;
155 match text.char_indices().find(|(index, _)| *index >= cut) {
156 Some((index, _)) => &text[index..],
157 // Unreachable in practice — the loop above finds the final boundary at worst —
158 // but an empty label is a correct token and a panic is not.
159 None => "",
160 }
161}
162
163/// Eight bytes of fresh randomness per call.
164///
165/// # Why `/dev/urandom` rather than a crate
166///
167/// The dependency set T-W2-2 pinned carries no CSPRNG — `backon` vendors `fastrand`,
168/// which is a deliberately non-cryptographic PRNG and is not re-exported — and this
169/// lane adds source files only, so adding one is not available. Reading
170/// `/dev/urandom` directly is what remains, and it is the same kernel pool
171/// `getrandom` reaches on Linux; the daemon this client drives is Linux-only, so no
172/// second platform needs a branch.
173///
174/// # Why the fallback is not a silent one
175///
176/// If the read fails, the fallback mixes `RandomState` (which is seeded from the same
177/// kernel pool at process start and is randomised per process — verified 2026-08-08:
178/// five draws distinct within a process, and different across processes) with the
179/// nanosecond clock and the address of a stack local. That is weaker than
180/// `/dev/urandom` and it is still per-attempt distinct, which is the property TRAP-1
181/// needs: the token must not repeat across two attempts, and it does not have to be
182/// unguessable by an adversary. A caller cannot reach this path deliberately, and the
183/// alternative — failing the build for want of eight bytes — turns an unreadable
184/// `/dev/urandom` into an unusable client.
185fn nonce_bytes() -> [u8; TOKEN_NONCE_BYTES] {
186 use std::io::Read as _;
187
188 let mut bytes = [0u8; TOKEN_NONCE_BYTES];
189 if let Ok(mut urandom) = std::fs::File::open("/dev/urandom")
190 && urandom.read_exact(&mut bytes).is_ok()
191 {
192 return bytes;
193 }
194
195 // The fallback. Three independent sources so no single one has to be good.
196 use std::collections::hash_map::RandomState;
197 use std::hash::{BuildHasher as _, Hasher as _};
198
199 let mut hasher = RandomState::new().build_hasher();
200 hasher.write_usize(&bytes as *const u8 as usize);
201 hasher.write_u128(
202 std::time::SystemTime::now()
203 .duration_since(std::time::UNIX_EPOCH)
204 .map(|since| since.as_nanos())
205 .unwrap_or_default(),
206 );
207 bytes.copy_from_slice(&hasher.finish().to_le_bytes());
208 bytes
209}
210
211#[cfg(test)]
212mod tests {
213 use std::collections::HashSet;
214
215 use proptest::prelude::*;
216
217 use super::*;
218
219 /// The worst legal scope, from the discovery that motivated the cap: a full
220 /// ap-northeast-1 image ARN carrying a 64-character image name. This is the value
221 /// that minted 142 characters before the label was truncated.
222 fn worst_legal_scope() -> String {
223 format!(
224 "arn:aws:lambda:ap-northeast-1:123456789012:microvm-image:{}",
225 "a".repeat(crate::constants::MAX_IMAGE_NAME_LEN)
226 )
227 }
228
229 /// The shape, against a fixed nonce so the assertion is on the format rather than
230 /// on a copy of the format. `create-`, the label, `-`, sixteen hex characters.
231 #[test]
232 fn a_token_is_the_verb_the_label_and_sixteen_hex_characters() {
233 let token = mint(
234 Verb::Create,
235 "agentd-conformance",
236 &[0xde, 0xad, 0xbe, 0xef, 0, 1, 2, 3],
237 );
238 assert_eq!(token, "create-agentd-conformance-deadbeef00010203");
239
240 let token = mint(Verb::Run, "vm-7", &[0xff; TOKEN_NONCE_BYTES]);
241 assert_eq!(token, "run-vm-7-ffffffffffffffff");
242 }
243
244 /// The two verbs are the two this client uses, and they are distinguishable in the
245 /// minted value — which is the only reason the verb is in there at all: a CloudTrail
246 /// reader seeing `create-…` knows which call it came from.
247 #[test]
248 fn the_verb_prefix_distinguishes_a_create_from_a_run() {
249 assert!(create_token("img").starts_with("create-"));
250 assert!(run_token("img").starts_with("run-"));
251 }
252
253 /// TRAP-1's headline property, at the scale the success criteria name: 200 draws,
254 /// 200 distinct values.
255 ///
256 /// **Falsification** — replace `nonce_bytes()` in `create_token` with a digest of
257 /// the scope (the exact mistake the Python client's `client_token=` parameter
258 /// invited) and this collapses to one distinct value out of 200. Verified by doing
259 /// it; see the packet's guard proofs.
260 #[test]
261 fn two_hundred_create_tokens_for_one_scope_are_two_hundred_distinct_values() {
262 let scope = "agentd-conformance";
263 let minted: HashSet<String> = (0..200).map(|_| create_token(scope)).collect();
264 assert_eq!(minted.len(), 200, "a repeated token is a wedged image");
265 }
266
267 /// The same for the run path, and across the two verbs at once: 200 of each, 400
268 /// distinct, so a create token can never equal a run token either.
269 #[test]
270 fn create_and_run_tokens_never_collide_with_each_other() {
271 let scope = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
272 let mut minted = HashSet::new();
273 for _ in 0..200 {
274 minted.insert(create_token(scope));
275 minted.insert(run_token(scope));
276 }
277 assert_eq!(minted.len(), 400);
278 }
279
280 /// The ceiling, at the value that discovered it. 142 characters before truncation,
281 /// and the assertion is on the real minted length rather than on the arithmetic.
282 #[test]
283 fn the_worst_legal_scope_fits_the_hundred_twenty_eight_character_ceiling() {
284 let scope = worst_legal_scope();
285 assert!(
286 scope.len() > MAX_TOKEN_SCOPE_LEN,
287 "the point of this scope is that it is longer than the label cap"
288 );
289
290 for token in [create_token(&scope), run_token(&scope)] {
291 assert!(
292 token.len() <= MAX_CLIENT_TOKEN_LEN,
293 "{} characters, over the {MAX_CLIENT_TOKEN_LEN} ceiling: {token}",
294 token.len()
295 );
296 }
297 }
298
299 /// The arithmetic behind the cap, stated so a change to any of the three numbers
300 /// fails here rather than on a launch. `run-` is the shorter verb, so `create-`
301 /// bounds the total.
302 #[test]
303 fn the_label_cap_leaves_room_for_the_longest_verb_and_the_whole_nonce() {
304 let longest_verb = Verb::Create.as_str().len();
305 let worst = longest_verb + 1 + MAX_TOKEN_SCOPE_LEN + 1 + TOKEN_NONCE_HEX_LEN;
306 assert!(
307 worst <= MAX_CLIENT_TOKEN_LEN,
308 "the worst legal token is {worst} characters against a ceiling of {MAX_CLIENT_TOKEN_LEN}"
309 );
310 assert_eq!(TOKEN_NONCE_HEX_LEN, TOKEN_NONCE_BYTES * 2);
311 }
312
313 /// The truncation keeps the **tail**. Two ARNs differing only in their resource
314 /// name must produce different labels — truncating the head would collapse them,
315 /// because every ARN in a region shares its prefix.
316 ///
317 /// The image names are long enough that the shared prefix really is what gets cut:
318 /// the ARN prefix alone is 57 characters, so a short name leaves the whole scope
319 /// under the 64-byte cap and the test would pass without exercising truncation at
320 /// all. That near-miss is the reason the length is asserted first.
321 #[test]
322 fn truncation_keeps_the_tail_so_two_arns_stay_distinguishable() {
323 let prefix = "arn:aws:lambda:ap-northeast-1:123456789012:microvm-image:";
324 let first_scope = format!("{prefix}alpha-conformance-image-name");
325 let second_scope = format!("{prefix}omega-conformance-image-name");
326 assert!(
327 first_scope.len() > MAX_TOKEN_SCOPE_LEN,
328 "these scopes must actually be truncated or the test proves nothing"
329 );
330
331 let first = mint(Verb::Run, &first_scope, &[0; TOKEN_NONCE_BYTES]);
332 let second = mint(Verb::Run, &second_scope, &[0; TOKEN_NONCE_BYTES]);
333
334 assert_ne!(first, second, "the distinguishing part is at the end");
335 assert!(first.contains("alpha-conformance-image-name"), "{first}");
336 assert!(second.contains("omega-conformance-image-name"), "{second}");
337 assert!(
338 !first.contains("arn:aws"),
339 "the shared prefix is what got cut: {first}"
340 );
341 }
342
343 /// The nonce survives truncation intact, whatever the label does. This is the
344 /// property that makes collision-freedom independent of the scope: the last sixteen
345 /// characters are always the sixteen hex digits.
346 #[test]
347 fn the_nonce_is_never_the_part_that_gets_cut() {
348 let scope = "z".repeat(4096);
349 let token = mint(Verb::Create, &scope, &[0xab; TOKEN_NONCE_BYTES]);
350 assert!(
351 token.ends_with("abababababababab"),
352 "the whole nonce must survive: {token}"
353 );
354 assert_eq!(
355 token.len(),
356 "create".len() + 1 + MAX_TOKEN_SCOPE_LEN + 1 + TOKEN_NONCE_HEX_LEN
357 );
358 }
359
360 /// A multi-byte scope does not panic and does not overshoot the ceiling. The cut
361 /// point can land inside a character, and slicing by byte index there is a panic in
362 /// a code path a caller reaches by naming an image in their own language.
363 #[test]
364 fn a_multibyte_scope_cuts_on_a_character_boundary_rather_than_panicking() {
365 for scope in ["日本語".repeat(64), "é".repeat(200), "🔥".repeat(50)] {
366 let token = create_token(&scope);
367 assert!(token.len() <= MAX_CLIENT_TOKEN_LEN, "{token}");
368 assert!(token.is_char_boundary(token.len()));
369 }
370 }
371
372 /// An empty scope is a legal token rather than a panic or an empty string: the
373 /// model's minimum is 1, and `create--<nonce>` clears it comfortably.
374 #[test]
375 fn an_empty_scope_still_mints_a_usable_token() {
376 let token = create_token("");
377 assert_eq!(token, format!("create--{}", &token[8..]));
378 // The clientToken shapes state `min: 1`, and nothing outside this crate would catch a
379 // violation of it — the note here used to say botocore enforces `min` locally, which is
380 // true of botocore and irrelevant to a client that signs with `aws-sigv4` and sends with
381 // `reqwest` (issue #24). What makes the constraint unreachable is not a guard but the
382 // minter: the verb and the nonce alone are 23 characters, so the empty label cannot
383 // collapse the token, and that is what the assertion below says.
384 assert_eq!(
385 token.len(),
386 "create".len() + 2 + TOKEN_NONCE_HEX_LEN,
387 "the model's clientToken min is 1 and this clears it: {token}"
388 );
389 }
390
391 proptest! {
392 /// The ceiling and the nonce, over arbitrary caller text. A hand-picked scope
393 /// set is exactly where the multi-byte boundary case hides, and the property
394 /// that matters — every minted token fits, and every one ends in its full nonce
395 /// — has to hold for text this test did not choose.
396 #[test]
397 fn every_scope_mints_a_token_that_fits_and_keeps_its_whole_nonce(scope: String) {
398 let token = mint(Verb::Create, &scope, &[0x5a; TOKEN_NONCE_BYTES]);
399 prop_assert!(token.len() <= MAX_CLIENT_TOKEN_LEN, "{}", token);
400 prop_assert!(token.ends_with("5a5a5a5a5a5a5a5a"), "{}", token);
401 prop_assert!(token.starts_with("create-"), "{}", token);
402 }
403
404 /// Two calls with the *same* scope differ, for any scope. The distinctness test
405 /// above uses one fixed scope; this one asserts the scope cannot be the thing
406 /// that makes two attempts agree — which is precisely what a digest-derived
407 /// token would do.
408 #[test]
409 fn no_scope_makes_two_attempts_produce_the_same_token(scope: String) {
410 prop_assert_ne!(create_token(&scope), create_token(&scope));
411 prop_assert_ne!(run_token(&scope), run_token(&scope));
412 }
413 }
414
415 /// The randomness source produces distinct draws. A `nonce_bytes` that returned a
416 /// constant would pass a format test and fail this one, and it is the single point
417 /// every distinctness property above rests on.
418 #[test]
419 fn the_nonce_source_does_not_repeat_itself() {
420 let drawn: HashSet<[u8; TOKEN_NONCE_BYTES]> = (0..200).map(|_| nonce_bytes()).collect();
421 assert_eq!(drawn.len(), 200);
422 assert!(
423 !drawn.contains(&[0u8; TOKEN_NONCE_BYTES]),
424 "an all-zero draw is the signature of a read that silently did nothing"
425 );
426 }
427}