Skip to content

Architecture · Module 2.1

Time Buckets

under construction

The first genuinely Zajil-specific module. It turns a wall-clock instant into a bucket index — the counter that address derivation feeds to HMAC. It is TOTP's counter, and nothing more: no hashing, no addresses, no clock access of its own.

History: forty years of turning secrets into codes

Understanding why this construction fits Zajil means understanding what problem each of its ancestors solved.

Year System Idea Limitation
1981 Lamport hash chains Hash a seed n times, reveal in reverse. Each value verifies against the next; captured values reveal nothing forward. Finite chain — runs out, needs re-provisioning. (Became S/KEY.)
1986 RSA SecurID Shared secret + time → a number changing each minute. Proprietary black box; vendor holds every secret (breached in 2011).
2004 OATH Consortium formed to break that vendor lock-in with open standards.
2005 HOTP (RFC 4226) Truncate(HMAC(K, counter)). Open, auditable. Counter is stored state on both sides — drifts, needs resync windows.
2011 TOTP (RFC 6238) HOTP where the counter is the clock: C = ⌊(now − T0) / X⌋. Trades a sync problem for a clock-integrity problem.

TOTP's entire innovation is that one substitution: delete the stored counter, read it off the clock instead. Two parties who have never communicated agree on the counter because they both look at the same clock.

Why this shape, and not something else

That property is not a nice-to-have for Zajil — it is the only shape that fits the problem. A client must derive a cell's address before any communication exists. You cannot negotiate a shared counter with a server you cannot yet reach; that is circular. Time is the only shared state obtainable without a channel.

So Zajil borrows TOTP's counter deliberately, not out of familiarity.

The construction

index = (now − epoch) / duration          # integer (floor) division

Three fields, all manifest tunables, define the schedule:

Field Meaning v1 candidate
duration_secs how long one bucket lasts 3600 (hourly)
epoch where bucket 0 begins unsettled
skew buckets accepted on each side 1 → 3 live buckets

Clocks drift, so a cell binds a window of indices, not one:

skew = 1  →  [ index−1 , index , index+1 ]   (3 live buckets)

This is TOTP's skew tolerance (RFC 6238 recommends ±1 step). A client asks "which bucket is it?" via index_at; a cell asks "which buckets do I accept?" via window_at. Same schedule, asymmetric use.

What we kept and what we threw away

Most of TOTP is interface convention for a human squinting at a phone. Zajil keeps the cryptographic core and discards the rest.

From TOTP In Zajil Why
counter = ⌊(now − T0)/X⌋ kept — this module The shared-state-without-a-channel property
Skew window keptwindow_at Clock drift is real
Truncation kept, but to raw bits No decimal, no modulo — see below
6-digit decimal + modulo dropped Human-interface tax; no human reads an address
Rate limiting, single-use dropped The output is not a credential

The digit machinery, and why we drop it

TOTP's & 0x7FFFFFFF mask exists only because Java has no unsigned integers — a crypto standard permanently shaped by a language's type system. Its 6-digit decimal conversion exists only so a human can read it, and going past 8 digits introduces modulo bias (at 10 digits, 78% of codes become unreachable). None of this applies to Zajil: no human ever reads an address, so we take raw HMAC bits directly and the entire question of digits and bias evaporates.

The lesson worth internalising: when borrowing a construction, separate what is cryptographically necessary from what is interface convention. Most of TOTP is the latter.

Skew costs something different here

For TOTP, widening the acceptance window linearly multiplies attack surface — ±1 triples the guess space (10⁻⁶ → 3×10⁻⁶). That is why the RFC is conservative.

For Zajil, widening the window costs resources, not security. Each accepted bucket is another row of addresses a cell must bind. With 30 slots, skew 1 means 90 bindings; skew 2 means 150. The security impact is nil — 2⁶⁴ does not get meaningfully easier when you triple it. So window sizing is an operations question, not a security one, and the RFC's conservatism does not transfer.

The clock is an attack surface

Since time is the shared state, whoever controls a client's clock controls its connectivity. Push the clock far enough (spoofed, unauthenticated NTP) and the client derives addresses nobody is listening on — a total denial of service, no cryptography broken.

Two defenses:

  • Planned: a root-signed time service, so spoofing a client's clock requires the root key. The security is in the signature, not in obscuring the epoch. (See open questions — an early idea to use an exotic calendar was dropped as obfuscation with zero attacker cost.)
  • Interim: a monotonic floor — persist the highest time ever observed and refuse any clock that jumps backward past it. Cheap, and it converts "attacker rewound your clock" from a silent failure into a detectable one. Derivation must use wall-clock time (the shared reference), but a monotonic clock can sanity-check it.

Leap seconds

Unix time is not a true count of SI seconds — leap seconds are repeated or smeared. With hourly buckets and skew tolerance, a one-second anomaly is invisible. Worth knowing it exists; it does bite systems that assume strict second-resolution monotonicity.

The module surface

Pure integer arithmetic, no_std, and — critically — it never reads a clock. Time always enters as an argument. That is what makes it deterministically testable and keeps Layer 7 simulation possible.

Type Role
UnixTime Seconds since the Unix epoch. Passed in, never read from a clock.
BucketIndex The derived counter. Public and predictable — carries no secrecy.
BucketSchedule duration + epoch + skew. Holds the arithmetic: index_at, start_of, window_at.
BucketWindow Inclusive range of accepted indices. Clamps at 0, saturates near max — never wraps.
MonotonicFloor Rejects backward clock jumps. Separate and stateful.

See engineering › implementation plan for where this sits in the ladder, and the module's own README.md and test suite for the test-first build in progress.