feat: F5.6c measured and accepted on test-filius; tickets up to 5 s ahead of F's clock

test-filius refused every ticket as from the future: its clock was 886 ms
behind the dashboard's machine, with NTP still 8 ms (one ticket in 125, as a
ticket carries whole seconds). F now takes a ticket signed up to 5 s ahead
(TICKET_AHEAD_SECONDS); itsdangerous refuses one second, and the oracle
keeps that on record.

tools/live measures and accepts on a live F, with fresh projects each run:
m3 (200 rooms of the largest example with pings: probe p95 37 ms), m4 (63 KB
document; dragging makes an update per pointer move, 59/s), m5 (an hour
through the proxy), acceptance (two browsers, 14 checks, passed three times)
and clock (F's clock against ours).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 19:22:22 +00:00
co-authored by Claude Opus 5.5
parent 087721cba3
commit 416d5fdea3
15 changed files with 630 additions and 7 deletions
+10 -3
View File
@@ -3,8 +3,13 @@
// JSON, zlib-packed when that is shorter (then a leading "."), base64url;
// the moment of signing as the shortest big-endian bytes, base64url; an
// HMAC-SHA1 over both under the key sha1(salt + "signer" + secret)
// ("django-concat"), base64url. Valid for TICKET_SECONDS after signing and
// not before it, as itsdangerous counts (whole seconds). What lies under a
// ("django-concat"), base64url. Valid for TICKET_SECONDS after signing, as
// itsdangerous counts (whole seconds), and — unlike itsdangerous, which
// refuses a ticket a second ahead — from TICKET_AHEAD_SECONDS before it:
// the dashboard's machine and F's each have their clock, and a ticket
// carries whole seconds. F's clock 8 ms behind refused one ticket in 125,
// 886 ms behind nearly nine in ten (measured on test-filius, 04.10.2026);
// a person then sees "invalid ticket" for nothing they did. What lies under a
// matching signature the dashboard made: its base64 is read as Node reads
// it (leniently, as Python does). Python's own tickets are the oracle
// (tools/ticket-oracle).
@@ -17,6 +22,8 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { inflateSync } from 'node:zlib';
export const TICKET_SECONDS = 60;
/** How far ahead of F's clock a ticket may be signed: two machines' clocks, never quite one. */
export const TICKET_AHEAD_SECONDS = 5;
export const TICKET_SALT = 'mdg-filius-ticket';
export const LEVELS = ['read', 'write'] as const;
export type Level = (typeof LEVELS)[number];
@@ -61,7 +68,7 @@ export function readSigned(ticket: string, secret: string, now: number, salt = T
if (stamp < 0) throw new TicketError('no timestamp');
const age = now - Buffer.from(value.slice(stamp + 1), 'base64url').reduce((sum, byte) => sum * 256 + byte, 0);
if (age > TICKET_SECONDS) throw new TicketError(`ticket ${age} s old`);
if (age < 0) throw new TicketError('ticket from the future');
if (age < -TICKET_AHEAD_SECONDS) throw new TicketError('ticket from the future');
const payload = value.slice(0, stamp);
const packed = payload.startsWith('.');
try {
+13 -3
View File
@@ -2,7 +2,8 @@
// (tools/ticket-oracle → tickets.python.json): every ticket the oracle made
// reads the same — the same payload, or refused where Python refuses it, for
// the same reason (a signature that does not match, an age beyond 60 s or
// below 0) —; tickets signed in Node carry the same JSON and the same moment
// more than 5 s ahead) —, except a ticket signed up to 5 s ahead of F's
// clock, which Python refuses and F takes (two machines' clocks); tickets signed in Node carry the same JSON and the same moment
// as Python's (packed by Node's zlib, Chromium's, the bytes differ; an
// unpacked one is the same to the byte); and
// the claims the server takes from a ticket: a person, a login, a project
@@ -11,7 +12,7 @@
import { readFileSync } from 'node:fs';
import { inflateSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { readSigned, signature, TICKET_SALT, TICKET_SECONDS, TicketError, verifyTicket } from '../src/ticket.js';
import { readSigned, signature, TICKET_AHEAD_SECONDS, TICKET_SALT, TICKET_SECONDS, TicketError, verifyTicket } from '../src/ticket.js';
import { signTicket } from './tickets.js';
interface OracleCase {
@@ -36,6 +37,8 @@ const unpacked = (ticket: string) => {
return (ticket.startsWith('.') ? inflateSync(bytes) : bytes).toString('utf8');
};
const byName = (name: string) => oracle.cases.find((each) => each.name === name)!;
/** Python refuses these, F takes them: signed ahead of F's clock, within TICKET_AHEAD_SECONDS. */
const AHEAD = ['fromTheFuture', 'fiveSecondsAhead'];
function outcome(ticket: string, now: number): { payload: unknown } | { error: string } {
try {
@@ -52,7 +55,7 @@ describe('the ticket as itsdangerous signs it', () => {
expect([TICKET_SALT, TICKET_SECONDS]).toEqual([oracle.salt, oracle.ttl]);
});
it.each(oracle.cases.map((each) => [each.name, each] as const))('reads „%s“ as Python does', (_name, each) => {
it.each(oracle.cases.filter((each) => !AHEAD.includes(each.name)).map((each) => [each.name, each] as const))('reads „%s“ as Python does', (_name, each) => {
expect(outcome(each.ticket, each.now)).toEqual(each.error === undefined ? { payload: each.payload } : { error: each.error });
});
@@ -66,6 +69,13 @@ describe('the ticket as itsdangerous signs it', () => {
expect(readSigned(ours, oracle.secret, oracle.signedAt)).toEqual(each.payload);
});
it.each(AHEAD)('takes „%s“, which Python refuses: a ticket from up to 5 s ahead of F\'s clock', (name) => {
const each = byName(name);
expect(each.error).toBe('expired');
expect(outcome(each.ticket, each.now)).toEqual({ payload: byName('plain').payload });
expect(oracle.signedAt - each.now).toBeLessThanOrEqual(TICKET_AHEAD_SECONDS);
});
it('covers a packed and an unpacked payload', () => {
expect(byName('plain').ticket.startsWith('.')).toBe(true);
expect(byName('listPayload').ticket.startsWith('.')).toBe(false);
+12
View File
@@ -88,6 +88,18 @@
"now": 1789999999,
"error": "expired"
},
{
"name": "fiveSecondsAhead",
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
"now": 1789999995,
"error": "expired"
},
{
"name": "sixSecondsAhead",
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
"now": 1789999994,
"error": "expired"
},
{
"name": "otherSecret",
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.4wnVSAZOvBXnfPb_aCCd2137AEI",