Skip to content

Commit 9927827

Browse files
committed
fix(node): address beardthelion round-six P3 findings on task cursor
- Pin the encode docstring compat claim: add unpadded_legacy_token_still_verifies test minting a token via the pre-padding recipe (siv + apply_keystream with no padding) and asserting decode returns the position. - Pin the PAYLOAD_BUCKET round-up claim: add oversized_payload_rounds_up_to_next_bucket test with a 140-char id crossing the 128-byte bucket, asserting round-trip at two buckets. - Correct timestamp-width figures: to_rfc3339() renders 25 chars with no fractional seconds (not 32); 32 is the microsecond width.
1 parent 8698cc2 commit 9927827

1 file changed

Lines changed: 82 additions & 2 deletions

File tree

‎crates/gitlawb-node/src/api/task_cursor.rs‎

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ fn apply_keystream(key: &TaskCursorKey, iv: &[u8; TAG_LEN], buf: &mut [u8]) {
232232
/// The serialized payload is padded with trailing whitespace to a fixed
233233
/// bucket before the SIV tag is computed: the keystream preserves plaintext
234234
/// length, so an unpadded payload would let the token's length leak the
235-
/// named row's field widths (a 32-char `to_rfc3339()` rendering mints a
235+
/// named row's field widths (a 25-char `to_rfc3339()` rendering mints a
236236
/// shorter token than a 35-char one). `decode` tolerates the padding because
237237
/// `serde_json` ignores trailing whitespace, and the tag covers the padded
238238
/// bytes, so a token minted before padding landed still verifies.
@@ -369,7 +369,7 @@ mod tests {
369369
let k = key();
370370
// Ids are server-minted fixed-length UUIDs; the timestamp is the
371371
// width that varies in production (`to_rfc3339()` renders 35 chars
372-
// with nanoseconds, 32 without fractional seconds).
372+
// with nanoseconds, 25 without fractional seconds).
373373
let id = "b3f7a1c2-4d5e-4f6a-8b9c-0d1e2f3a4b5c";
374374
let narrow = encode(
375375
&k,
@@ -395,6 +395,86 @@ mod tests {
395395
);
396396
}
397397

398+
/// A token minted before the fixed-width padding landed (unpadded payload,
399+
/// tag over the raw bytes) must still verify: `decode` MACs whatever
400+
/// plaintext the keystream recovers and `serde_json` tolerates the
401+
/// missing trailing whitespace. This pins the back-compat claim in the
402+
/// `encode` docstring using the pre-change recipe directly.
403+
#[test]
404+
fn unpadded_legacy_token_still_verifies() {
405+
let k = key();
406+
let filter = unfiltered();
407+
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", "legacy-task");
408+
// Pre-padding recipe: serialize, tag, and encrypt with no padding.
409+
let mut payload = serde_json::to_vec(&CursorPayload {
410+
t: &position.created_at,
411+
i: &position.id,
412+
e: chrono::Utc::now().timestamp() + CURSOR_TTL_SECS,
413+
})
414+
.expect("cursor payload is plain strings and an integer");
415+
assert!(
416+
payload.len() < PAYLOAD_BUCKET,
417+
"fixture must stay under one bucket so the legacy shape is unpadded"
418+
);
419+
let iv = siv(&k, filter, None, &payload);
420+
apply_keystream(&k, &iv, &mut payload);
421+
let token = format!(
422+
"{CURSOR_PREFIX}.{}.{}",
423+
URL_SAFE_NO_PAD.encode(iv),
424+
URL_SAFE_NO_PAD.encode(&payload)
425+
);
426+
assert_eq!(
427+
decode(&k, filter, None, &token).unwrap(),
428+
position,
429+
"pre-padding token must verify under the current decode"
430+
);
431+
}
432+
433+
/// The `PAYLOAD_BUCKET` docstring promises an oversized payload rounds up
434+
/// to the next multiple instead of failing. Mint a position whose id
435+
/// pushes the serialized payload past one bucket and require the token
436+
/// to round-trip (at two buckets).
437+
#[test]
438+
fn oversized_payload_rounds_up_to_next_bucket() {
439+
let k = key();
440+
// ~140-char id: payload crosses the 128-byte bucket.
441+
let long_id = "a".repeat(140);
442+
let position = TaskPosition::new("2026-01-03T00:00:00+00:00", long_id.clone());
443+
let probe = serde_json::to_vec(&CursorPayload {
444+
t: &position.created_at,
445+
i: &position.id,
446+
e: 1_700_000_000,
447+
})
448+
.expect("cursor payload is plain strings and an integer");
449+
assert!(
450+
probe.len() > PAYLOAD_BUCKET,
451+
"fixture payload must exceed one bucket: {} bytes",
452+
probe.len()
453+
);
454+
assert!(
455+
probe.len() <= 2 * PAYLOAD_BUCKET,
456+
"fixture payload must fit in two buckets: {} bytes",
457+
probe.len()
458+
);
459+
let token = encode(&k, unfiltered(), None, &position);
460+
assert_eq!(
461+
decode(&k, unfiltered(), None, &token).unwrap(),
462+
position,
463+
"oversized payload must round-trip at two buckets"
464+
);
465+
// Two buckets of ciphertext must be longer than one bucket's worth.
466+
let single = encode(
467+
&k,
468+
unfiltered(),
469+
None,
470+
&TaskPosition::new("2026-01-03T00:00:00+00:00", "task-a"),
471+
);
472+
assert!(
473+
token.len() > single.len(),
474+
"two-bucket token must be longer than a one-bucket token"
475+
);
476+
}
477+
398478
/// Callers paste the token straight into a query string, so it must carry
399479
/// no character that needs percent-encoding.
400480
#[test]

0 commit comments

Comments
 (0)