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 crate::constants::MAX_CLIENT_TOKEN_LEN;
46
47/// Bytes of randomness folded into every token: eight, so two attempts one second
48/// apart do not collide even under a retry storm.
49///
50/// Rendered as [`TOKEN_NONCE_HEX_LEN`] hex characters. Both constants exist because
51/// the *hex* length is what the ceiling arithmetic needs and deriving it in three
52/// places is three places to get the factor of two wrong.
53const TOKEN_NONCE_BYTES: usize = 8;
54
55/// The nonce's rendered width, `TOKEN_NONCE_BYTES * 2`.
56///
57/// Test-only, and deliberately so: production code renders the nonce by iterating the bytes,
58/// so a second constant it *used* would be a second thing to keep in step. The tests below
59/// need it to check the ceiling arithmetic — and they compare it against
60/// `TOKEN_NONCE_BYTES * 2` rather than trusting it, so a change to either is caught.
61#[cfg(test)]
62const TOKEN_NONCE_HEX_LEN: usize = TOKEN_NONCE_BYTES * 2;
63
64/// How much of the scope label survives into a token.
65///
66/// Not a cosmetic cap — see the module docs for the 142-character token that motivated
67/// it. Sized so that the worst legal scope still fits [`MAX_CLIENT_TOKEN_LEN`]: the
68/// test at the bottom of this file asserts the arithmetic rather than trusting it.
69const MAX_TOKEN_SCOPE_LEN: usize = 64;
70
71/// The verb prefix, closed over the two operations that carry an idempotency token on
72/// this client's paths.
73///
74/// An enum rather than a `&str` parameter because a third verb is a new operation and
75/// should be a change to this file, not a string a call site invents. `UpdateMicrovmImage`
76/// is the third `idempotencyToken: true` member in the model and is deliberately absent:
77/// this client never calls it.
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79enum Verb {
80 Create,
81 Run,
82}
83
84impl Verb {
85 fn as_str(self) -> &'static str {
86 match self {
87 Verb::Create => "create",
88 Verb::Run => "run",
89 }
90 }
91}
92
93/// An image-create idempotency token, unique per attempt.
94///
95/// `scope` is a **label only** — a readability aid for whoever reads CloudTrail, folded
96/// in beside the nonce. It cannot become the token, and there is no parameter that can:
97/// see the module docs for the fifteen-hour wedge that closure exists to prevent
98/// (`docs/PLATFORM.md`, "`clientToken` is a permanent idempotency key").
99pub fn create_token(scope: &str) -> String {
100 mint(Verb::Create, scope, &nonce_bytes())
101}
102
103/// A run idempotency token, unique per attempt. Same rule as [`create_token`].
104///
105/// Cheaper to get wrong than the image case — a replayed run returns the original
106/// MicroVM rather than wedging anything — but the failure is worse to read: a caller
107/// who asked for a second VM gets the first one's id back, and two callers then drive
108/// the same guest.
109pub fn run_token(scope: &str) -> String {
110 mint(Verb::Run, scope, &nonce_bytes())
111}
112
113/// A caller's log-stream prefix with a fresh per-build discriminator: `<prefix>/<16 hex>`.
114///
115/// # The same mechanism as the token nonce, for the same class of reason
116///
117/// `logging.cloudWatch.logStream` is an **exact** stream name on the wire — prefixes are
118/// unsupported (docs/PLATFORM.md, 'An image build is three VMs and three log streams') —
119/// and one image build emits three log streams. A caller's fixed name sent verbatim
120/// collapses all three of every build, concurrent builds of different images included,
121/// into one stream nothing can tell apart. So the caller's value is a prefix by contract
122/// ([`crate::control::CreateImageRequest::log_stream`]), and this appends eight bytes of
123/// fresh CSPRNG rendered as sixteen hex characters per create attempt — the exact nonce
124/// [`create_token`] folds into an idempotency token, reused rather than reinvented.
125///
126/// The arithmetic that keeps the result legal is `MAX_USER_LOG_STREAM_LEN`'s: the caller's
127/// prefix is capped at 495 before this runs, so `495 + 1 + 16` is exactly the shape's 512
128/// ceiling. The debug assertion holds that rather than trusting it.
129pub(crate) fn resolve_log_stream(prefix: &str) -> String {
130 let nonce = nonce_bytes();
131 let mut resolved = String::with_capacity(prefix.len() + 1 + nonce.len() * 2);
132 resolved.push_str(prefix);
133 resolved.push('/');
134 resolved.push_str(&const_hex::encode(nonce));
135 debug_assert!(
136 resolved.len() <= crate::constants::MAX_LOG_STREAM_LEN,
137 "resolved a {}-character stream name, over the {} ceiling: {resolved}",
138 resolved.len(),
139 crate::constants::MAX_LOG_STREAM_LEN,
140 );
141 resolved
142}
143
144/// The one place a token is assembled, with the randomness passed in.
145///
146/// Split from the two public functions so a test can drive the *format* with a fixed
147/// nonce and still exercise the real assembly, rather than reimplementing the format in
148/// the test and asserting a copy of it against itself.
149fn mint(verb: Verb, scope: &str, nonce: &[u8]) -> String {
150 // The tail, and on a character boundary: `scope` is arbitrary caller text, so
151 // slicing by byte index would panic on a multi-byte character straddling the cut.
152 // `char_indices` finds the first boundary at or after the cut point, which keeps at
153 // most MAX_TOKEN_SCOPE_LEN bytes — never more, which is what the ceiling needs.
154 let label = tail(scope, MAX_TOKEN_SCOPE_LEN);
155
156 let mut token = String::with_capacity(verb.as_str().len() + 2 + label.len() + nonce.len() * 2);
157 token.push_str(verb.as_str());
158 token.push('-');
159 token.push_str(label);
160 token.push('-');
161 // Two lowercase hex digits per byte, so the rendered width is exactly
162 // 2 * nonce.len() and the ceiling arithmetic below holds.
163 token.push_str(&const_hex::encode(nonce));
164
165 debug_assert!(
166 token.len() <= MAX_CLIENT_TOKEN_LEN,
167 "minted a {}-character token, over the {MAX_CLIENT_TOKEN_LEN} ceiling: {token}",
168 token.len()
169 );
170 token
171}
172
173/// The last `max_bytes` bytes of `text`, cut at a character boundary.
174///
175/// Returns a possibly-shorter slice when the boundary falls inside a multi-byte
176/// character, which is the safe direction: the ceiling is an upper bound.
177fn tail(text: &str, max_bytes: usize) -> &str {
178 if text.len() <= max_bytes {
179 return text;
180 }
181 let cut = text.len() - max_bytes;
182 match text.char_indices().find(|(index, _)| *index >= cut) {
183 Some((index, _)) => &text[index..],
184 // Unreachable in practice — the loop above finds the final boundary at worst —
185 // but an empty label is a correct token and a panic is not.
186 None => "",
187 }
188}
189
190/// Eight bytes of fresh randomness per call, from the kernel CSPRNG via `getrandom`.
191///
192/// TRAP-1 needs per-attempt distinctness — the token must not repeat across two
193/// attempts; it does not have to be unguessable. `getrandom` gives the stronger
194/// property anyway, and fails only when the OS pool is genuinely unavailable, in
195/// which state a clock-derived nonce (the old fallback here) risks the exact
196/// collision TRAP-1 exists to prevent.
197fn nonce_bytes() -> [u8; TOKEN_NONCE_BYTES] {
198 let mut bytes = [0u8; TOKEN_NONCE_BYTES];
199 getrandom::fill(&mut bytes).expect("the OS random pool is available");
200 bytes
201}
202
203#[cfg(test)]
204mod tests {
205 use std::collections::HashSet;
206
207 use proptest::prelude::*;
208
209 use super::*;
210
211 /// The worst legal scope, from the discovery that motivated the cap: a full
212 /// ap-northeast-1 image ARN carrying a 64-character image name. This is the value
213 /// that minted 142 characters before the label was truncated.
214 fn worst_legal_scope() -> String {
215 format!(
216 "arn:aws:lambda:ap-northeast-1:123456789012:microvm-image:{}",
217 "a".repeat(crate::constants::MAX_IMAGE_NAME_LEN)
218 )
219 }
220
221 /// The shape, against a fixed nonce so the assertion is on the format rather than
222 /// on a copy of the format. `create-`, the label, `-`, sixteen hex characters.
223 #[test]
224 fn a_token_is_the_verb_the_label_and_sixteen_hex_characters() {
225 let token = mint(
226 Verb::Create,
227 "agentd-conformance",
228 &[0xde, 0xad, 0xbe, 0xef, 0, 1, 2, 3],
229 );
230 assert_eq!(token, "create-agentd-conformance-deadbeef00010203");
231
232 let token = mint(Verb::Run, "vm-7", &[0xff; TOKEN_NONCE_BYTES]);
233 assert_eq!(token, "run-vm-7-ffffffffffffffff");
234 }
235
236 /// The two verbs are the two this client uses, and they are distinguishable in the
237 /// minted value — which is the only reason the verb is in there at all: a CloudTrail
238 /// reader seeing `create-…` knows which call it came from.
239 #[test]
240 fn the_verb_prefix_distinguishes_a_create_from_a_run() {
241 assert!(create_token("img").starts_with("create-"));
242 assert!(run_token("img").starts_with("run-"));
243 }
244
245 /// TRAP-1's headline property, at the scale the success criteria name: 200 draws,
246 /// 200 distinct values.
247 ///
248 /// **Falsification** — replace `nonce_bytes()` in `create_token` with a digest of
249 /// the scope (the exact mistake the Python client's `client_token=` parameter
250 /// invited) and this collapses to one distinct value out of 200. Verified by doing
251 /// it; see the packet's guard proofs.
252 #[test]
253 fn two_hundred_create_tokens_for_one_scope_are_two_hundred_distinct_values() {
254 let scope = "agentd-conformance";
255 let minted: HashSet<String> = (0..200).map(|_| create_token(scope)).collect();
256 assert_eq!(minted.len(), 200, "a repeated token is a wedged image");
257 }
258
259 /// The same for the run path, and across the two verbs at once: 200 of each, 400
260 /// distinct, so a create token can never equal a run token either.
261 #[test]
262 fn create_and_run_tokens_never_collide_with_each_other() {
263 let scope = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
264 let mut minted = HashSet::new();
265 for _ in 0..200 {
266 minted.insert(create_token(scope));
267 minted.insert(run_token(scope));
268 }
269 assert_eq!(minted.len(), 400);
270 }
271
272 /// The ceiling, at the value that discovered it. 142 characters before truncation,
273 /// and the assertion is on the real minted length rather than on the arithmetic.
274 #[test]
275 fn the_worst_legal_scope_fits_the_hundred_twenty_eight_character_ceiling() {
276 let scope = worst_legal_scope();
277 assert!(
278 scope.len() > MAX_TOKEN_SCOPE_LEN,
279 "the point of this scope is that it is longer than the label cap"
280 );
281
282 for token in [create_token(&scope), run_token(&scope)] {
283 assert!(
284 token.len() <= MAX_CLIENT_TOKEN_LEN,
285 "{} characters, over the {MAX_CLIENT_TOKEN_LEN} ceiling: {token}",
286 token.len()
287 );
288 }
289 }
290
291 /// The arithmetic behind the cap, stated so a change to any of the three numbers
292 /// fails here rather than on a launch. `run-` is the shorter verb, so `create-`
293 /// bounds the total.
294 #[test]
295 fn the_label_cap_leaves_room_for_the_longest_verb_and_the_whole_nonce() {
296 let longest_verb = Verb::Create.as_str().len();
297 let worst = longest_verb + 1 + MAX_TOKEN_SCOPE_LEN + 1 + TOKEN_NONCE_HEX_LEN;
298 assert!(
299 worst <= MAX_CLIENT_TOKEN_LEN,
300 "the worst legal token is {worst} characters against a ceiling of {MAX_CLIENT_TOKEN_LEN}"
301 );
302 assert_eq!(TOKEN_NONCE_HEX_LEN, TOKEN_NONCE_BYTES * 2);
303 }
304
305 /// The truncation keeps the **tail**. Two ARNs differing only in their resource
306 /// name must produce different labels — truncating the head would collapse them,
307 /// because every ARN in a region shares its prefix.
308 ///
309 /// The image names are long enough that the shared prefix really is what gets cut:
310 /// the ARN prefix alone is 57 characters, so a short name leaves the whole scope
311 /// under the 64-byte cap and the test would pass without exercising truncation at
312 /// all. That near-miss is the reason the length is asserted first.
313 #[test]
314 fn truncation_keeps_the_tail_so_two_arns_stay_distinguishable() {
315 let prefix = "arn:aws:lambda:ap-northeast-1:123456789012:microvm-image:";
316 let first_scope = format!("{prefix}alpha-conformance-image-name");
317 let second_scope = format!("{prefix}omega-conformance-image-name");
318 assert!(
319 first_scope.len() > MAX_TOKEN_SCOPE_LEN,
320 "these scopes must actually be truncated or the test proves nothing"
321 );
322
323 let first = mint(Verb::Run, &first_scope, &[0; TOKEN_NONCE_BYTES]);
324 let second = mint(Verb::Run, &second_scope, &[0; TOKEN_NONCE_BYTES]);
325
326 assert_ne!(first, second, "the distinguishing part is at the end");
327 assert!(first.contains("alpha-conformance-image-name"), "{first}");
328 assert!(second.contains("omega-conformance-image-name"), "{second}");
329 assert!(
330 !first.contains("arn:aws"),
331 "the shared prefix is what got cut: {first}"
332 );
333 }
334
335 /// The nonce survives truncation intact, whatever the label does. This is the
336 /// property that makes collision-freedom independent of the scope: the last sixteen
337 /// characters are always the sixteen hex digits.
338 #[test]
339 fn the_nonce_is_never_the_part_that_gets_cut() {
340 let scope = "z".repeat(4096);
341 let token = mint(Verb::Create, &scope, &[0xab; TOKEN_NONCE_BYTES]);
342 assert!(
343 token.ends_with("abababababababab"),
344 "the whole nonce must survive: {token}"
345 );
346 assert_eq!(
347 token.len(),
348 "create".len() + 1 + MAX_TOKEN_SCOPE_LEN + 1 + TOKEN_NONCE_HEX_LEN
349 );
350 }
351
352 /// A multi-byte scope does not panic and does not overshoot the ceiling. The cut
353 /// point can land inside a character, and slicing by byte index there is a panic in
354 /// a code path a caller reaches by naming an image in their own language.
355 #[test]
356 fn a_multibyte_scope_cuts_on_a_character_boundary_rather_than_panicking() {
357 for scope in ["日本語".repeat(64), "é".repeat(200), "🔥".repeat(50)] {
358 let token = create_token(&scope);
359 assert!(token.len() <= MAX_CLIENT_TOKEN_LEN, "{token}");
360 assert!(token.is_char_boundary(token.len()));
361 }
362 }
363
364 /// An empty scope is a legal token rather than a panic or an empty string: the
365 /// model's minimum is 1, and `create--<nonce>` clears it comfortably.
366 #[test]
367 fn an_empty_scope_still_mints_a_usable_token() {
368 let token = create_token("");
369 assert_eq!(token, format!("create--{}", &token[8..]));
370 // The clientToken shapes state `min: 1`, and nothing outside this crate would catch a
371 // violation of it — the note here used to say botocore enforces `min` locally, which is
372 // true of botocore and irrelevant to a client that signs with `aws-sigv4` and sends with
373 // `reqwest` (issue #24). What makes the constraint unreachable is not a guard but the
374 // minter: the verb and the nonce alone are 23 characters, so the empty label cannot
375 // collapse the token, and that is what the assertion below says.
376 assert_eq!(
377 token.len(),
378 "create".len() + 2 + TOKEN_NONCE_HEX_LEN,
379 "the model's clientToken min is 1 and this clears it: {token}"
380 );
381 }
382
383 proptest! {
384 /// The ceiling and the nonce, over arbitrary caller text. A hand-picked scope
385 /// set is exactly where the multi-byte boundary case hides, and the property
386 /// that matters — every minted token fits, and every one ends in its full nonce
387 /// — has to hold for text this test did not choose.
388 #[test]
389 fn every_scope_mints_a_token_that_fits_and_keeps_its_whole_nonce(scope: String) {
390 let token = mint(Verb::Create, &scope, &[0x5a; TOKEN_NONCE_BYTES]);
391 prop_assert!(token.len() <= MAX_CLIENT_TOKEN_LEN, "{}", token);
392 prop_assert!(token.ends_with("5a5a5a5a5a5a5a5a"), "{}", token);
393 prop_assert!(token.starts_with("create-"), "{}", token);
394 }
395
396 /// Two calls with the *same* scope differ, for any scope. The distinctness test
397 /// above uses one fixed scope; this one asserts the scope cannot be the thing
398 /// that makes two attempts agree — which is precisely what a digest-derived
399 /// token would do.
400 #[test]
401 fn no_scope_makes_two_attempts_produce_the_same_token(scope: String) {
402 prop_assert_ne!(create_token(&scope), create_token(&scope));
403 prop_assert_ne!(run_token(&scope), run_token(&scope));
404 }
405 }
406
407 /// A resolved log stream is the caller's prefix, a `/`, and sixteen hex characters —
408 /// never the prefix verbatim — and two resolutions of one prefix differ.
409 ///
410 /// The distinctness is the whole point: one image build is three VMs writing three
411 /// streams under an exact-name member, so a resolution that could repeat would
412 /// collapse two builds' streams the way a verbatim name collapses every build's.
413 ///
414 /// **Falsification** — return `prefix.to_string()` from `resolve_log_stream` and the
415 /// format assertions and the distinctness assertion all go red.
416 #[test]
417 fn a_resolved_log_stream_carries_the_prefix_a_slash_and_sixteen_fresh_hex() {
418 let first = resolve_log_stream("ci-image");
419 let second = resolve_log_stream("ci-image");
420
421 for resolved in [&first, &second] {
422 assert!(resolved.starts_with("ci-image/"), "{resolved}");
423 let suffix = &resolved["ci-image/".len()..];
424 assert_eq!(suffix.len(), TOKEN_NONCE_HEX_LEN, "{resolved}");
425 assert!(
426 suffix.bytes().all(|b| b.is_ascii_hexdigit()),
427 "the discriminator is hex: {resolved}"
428 );
429 assert_ne!(resolved.as_str(), "ci-image", "never the prefix verbatim");
430 }
431 assert_ne!(
432 first, second,
433 "two creates from one request must resolve to two streams, or concurrent \
434 builds collapse into one"
435 );
436 }
437
438 /// The worst legal prefix — `MAX_USER_LOG_STREAM_LEN` characters — resolves to exactly
439 /// the shape's 512 ceiling, which is the arithmetic the 495 cap exists to hold.
440 #[test]
441 fn the_longest_legal_prefix_resolves_to_exactly_the_shapes_ceiling() {
442 let prefix = "s".repeat(crate::constants::MAX_USER_LOG_STREAM_LEN);
443 let resolved = resolve_log_stream(&prefix);
444 assert_eq!(resolved.len(), crate::constants::MAX_LOG_STREAM_LEN);
445 }
446
447 /// The randomness source produces distinct draws. A `nonce_bytes` that returned a
448 /// constant would pass a format test and fail this one, and it is the single point
449 /// every distinctness property above rests on.
450 #[test]
451 fn the_nonce_source_does_not_repeat_itself() {
452 let drawn: HashSet<[u8; TOKEN_NONCE_BYTES]> = (0..200).map(|_| nonce_bytes()).collect();
453 assert_eq!(drawn.len(), 200);
454 assert!(
455 !drawn.contains(&[0u8; TOKEN_NONCE_BYTES]),
456 "an all-zero draw is the signature of a read that silently did nothing"
457 );
458 }
459}