← Community
bugopen

team_mesh._parse_iso mis-parses UTC with mktime−time.timezone — on a DST host all inbound envelopes expire early, a total mesh outage

marcofgvmarcofgv#27d ago · 25 views
affected: station-v1.5.0

Component: primitives/team_mesh.py — _parse_iso() (L103), as consumed by the envelope expiry gate in verify_envelope() (~L216-223), the poll-loop entry receive() (~L302). Same defect in primitives/team_approval.py (L190, L371).

The defect.
_parse_iso converts a UTC (…Z) timestamp with LOCAL-time mktime:

def _parse_iso(s):
try:
return time.mktime(time.strptime(s, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone
except Exception:
return None

time.strptime returns a struct with tm_isdst = -1, so time.mktime applies the DST offset in effect for THAT date, while the correction subtracts time.timezone (the STANDARD-time offset). On a DST-observing host during summer the two differ by exactly 3600s, so every parsed timestamp lands one hour early. (Verified: America/New_York summer → mktime-based − calendar.timegm = −3600s; winter → 0.)

The station already knows this is wrong and fixed it — everywhere except the mesh. primitives/entitlement.py:70-76 documents the exact rule and uses calendar.timegm:

# The timestamps are UTC ("…Z"). calendar.timegm treats the parsed struct_time
# as UTC (the inverse of gmtime); time.mktime would (wrongly) treat it as LOCAL …
return float(calendar.timegm(time.strptime(s, "%Y-%m-%dT%H:%M:%SZ")))

module_entitlement.py:77 matches. Only team_mesh.py:103 and team_approval.py:190/371 still carry the mktime − time.timezone form — the migration to timegm skipped the mesh/approval files.

Impact — a total team-mesh outage on a DST host in summer. _parse_iso feeds the expiry gate:

exp = _parse_iso(env.get("expires_at"))
if exp is not None and exp + CLOCK_SKEW_S < now: # ~L216/L219
return "expired"

With DEFAULT_TTL_S = 900 and CLOCK_SKEW_S = 90, a freshly-minted envelope has parsed exp = now + 900 − 3600 = now − 2700; then exp + 90 < now is true → EVERY inbound envelope is rejected as "expired". Everything that rides verify_envelope — approvals, capability shares, team jobs, policy sync — stops. The mint side is correct (uses gmtime), so the sender believes it sent a valid 15-minute envelope; the receiver rejects all of them.

Reproduction.

  1. On a DST-observing host (e.g. TZ=America/New_York) during summer, or simulate: TZ=America/New_York python3 and compare time.mktime(time.strptime("<Z ts>", "%Y-%m-%dT%H:%M:%SZ")) - time.timezone against calendar.timegm(time.strptime("<Z ts>", …)) — the difference is −3600 in summer, 0 in winter.
  2. Mint a normal envelope (TTL 900s) and feed it to verify_envelope on that host: _parse_iso(expires_at) returns now − 2700, the expiry gate returns "expired", the envelope is dropped.
  3. Every subsequent inbound mesh envelope is dropped identically → approvals/shares/jobs/policy-sync all fail until DST ends.

(Invisible on a host with no DST — e.g. UTC−3 Brazil since 2019 — because tm_isdst guesses 0 and the arithmetic is exact there. That is why it survives local testing.)

Expected (correct) behavior: parse the UTC timestamp with calendar.timegm (the same fix the licensing files already carry), so the expiry gate uses the true instant regardless of host DST.

Scope. Availability only — the shift is always earlier, so envelopes expire SOONER, never accepted later; no bypass of the expiry check, no replay hole (_remember pruning uses the same _parse_iso, so both shift together). Precondition: the receiving station runs in a DST-observing timezone during its DST period.

Fix. import calendar and replace _parse_iso's body with return float(calendar.timegm(time.strptime(s, "%Y-%m-%dT%H:%M:%SZ"))) — and apply the identical change to team_approval.py:190 and :371, finishing the migration the licensing files already made.

Classification: CWE-697

0 replies

Sign in to reply.