diff --git a/project-facts.json b/project-facts.json index 6cc6735..fcbcc4f 100644 --- a/project-facts.json +++ b/project-facts.json @@ -4,7 +4,7 @@ "decision": "0.2.10", "semanticDecision": "0.3.0" }, - "testCount": 226, + "testCount": 229, "discoveredHashRouteLiteralCount": 8, "browserModes": [ "desktop-light", diff --git a/scripts/browser_context_e2e.py b/scripts/browser_context_e2e.py index 84a7672..d7a8e01 100644 --- a/scripts/browser_context_e2e.py +++ b/scripts/browser_context_e2e.py @@ -7,7 +7,6 @@ import http.server import json import os -import socketserver import threading from copy import deepcopy from pathlib import Path @@ -120,7 +119,7 @@ def packet_fixture() -> dict: def run() -> None: handler = functools.partial(QuietHandler, directory=str(SITE)) - with socketserver.TCPServer(("127.0.0.1", 0), handler) as server: + with http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) as server: thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() base = f"http://127.0.0.1:{server.server_address[1]}/" diff --git a/site/manifest.sha256 b/site/manifest.sha256 index a3d5421..032b651 100644 --- a/site/manifest.sha256 +++ b/site/manifest.sha256 @@ -24,7 +24,7 @@ ada77b250aace00f9b9310d9e5447aed6c5884140b6fb1260ad9eeefb7524072 src/guided-fra 1eeba312c1d36366f056af836a990edf3f1b9fae254ba79425253c887bcdef58 src/lib/decision-hinge.js f5a1892210355a1f2fada1f7af2d850758fb820b1e483d3c578a6a20d1485cfd src/lib/decision.js b7214b1632b94496f09ee8fe7d0268ea420e33fac260a47dbdcc7f380634fca0 src/lib/evidence-readiness.js -acf5f06b3935f5e2bf76674e02c0f7d1c71a27edec468eafa36af68f8c94cd8b src/lib/governed-context.js +5198f58a81caf8fb239d2085358ca0286965c61644329ef0dd5a033c77148edf src/lib/governed-context.js 2b613558bcf8c5751f9ca39363699548856364f3bc988f2b742a30bc684e59fa src/lib/input-boundaries.js ccf92df01cc80bbb10f340fbcaddccb34f0fe4d318ea414ef30e198aecd87a50 src/lib/input.js 752531dea7b1be2bddfdf00f38abe2f6cfd6739fc48f8a5f97cf4741aaf65e47 src/lib/intake.js diff --git a/site/src/lib/governed-context.js b/site/src/lib/governed-context.js index dd8ae59..131c3f6 100644 --- a/site/src/lib/governed-context.js +++ b/site/src/lib/governed-context.js @@ -18,6 +18,8 @@ const REQUIRED_V03 = [ const SUMMARY_KEYS = ['assumed', 'contradicted', 'expired', 'known', 'unknown']; const SUPPORTED_CLASSIFICATIONS = new Set(['PRIVATE', 'PROTECTED']); const SHA256_RE = /^[a-f0-9]{64}$/; +// Date-based freshness checks retain their existing exclusion of leap-second timestamps. +const TIMESTAMP_RE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt]([01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; const PACKET_ID_RE = /^DCP-[A-Z0-9-]+$/; const LEGACY_DIGEST_SCOPE = 'packet excluding provenance.generated_at and content_sha256'; const PAYLOAD_SCOPE = 'decision-relevant packet content excluding integrity, origin, freshness, and provenance'; @@ -47,7 +49,15 @@ function objectsOnly(value) { } function parseTimestamp(value) { - if (typeof value !== 'string' || !value.trim()) return null; + if (typeof value !== 'string') return null; + const parts = TIMESTAMP_RE.exec(value); + if (!parts || parts[0] !== value) return null; + const year = Number(parts[1]); + const month = Number(parts[2]); + const day = Number(parts[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const monthDays = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (day > monthDays[month - 1]) return null; const date = new Date(value); return Number.isFinite(date.getTime()) ? date : null; } @@ -148,7 +158,10 @@ function freshnessState(packet, now) { const sourceAsOf = parseTimestamp(freshness?.source_as_of); const reviewDue = freshness?.review_due_at === null ? null : parseTimestamp(freshness?.review_due_at); const validUntil = freshness?.valid_until === null ? null : parseTimestamp(freshness?.valid_until); - if (!issued || !sourceAsOf) return { state: 'INVALID', error: 'Packet freshness timestamps are invalid.' }; + if (!issued) return { state: 'INVALID', error: 'Packet issued_at must be a supported RFC 3339 timestamp.' }; + if (!sourceAsOf) return { state: 'INVALID', error: 'Packet source_as_of must be a supported RFC 3339 timestamp.' }; + if (freshness.review_due_at !== null && !reviewDue) return { state: 'INVALID', error: 'Packet review_due_at must be a supported RFC 3339 timestamp or null.' }; + if (freshness.valid_until !== null && !validUntil) return { state: 'INVALID', error: 'Packet valid_until must be a supported RFC 3339 timestamp or null.' }; if (sourceAsOf > issued) return { state: 'INVALID', error: 'Packet source_as_of is later than issued_at.' }; if (issued.getTime() > now.getTime() + CLOCK_SKEW_MS) return { state: 'INVALID', error: 'Packet issued_at is unacceptably future-dated.' }; if (reviewDue && reviewDue < sourceAsOf) return { state: 'INVALID', error: 'Packet review_due_at is earlier than source_as_of.' }; diff --git a/tests/governed-context.test.js b/tests/governed-context.test.js index 7354b57..50f186b 100644 --- a/tests/governed-context.test.js +++ b/tests/governed-context.test.js @@ -180,6 +180,73 @@ test('missing freshness boundaries are not active-eligible', async () => { assert.equal(result.trust.freshness, 'NOT_ESTABLISHED'); }); +test('freshness timestamps reject malformed formats, types, and impossible dates with recomputed integrity', async () => { + const invalid = [ + '', ' ', 'not-a-date', 42, false, {}, [], + 'September 16, 2026', '2026-09-16', '2026-09-16 00:00:00Z', + '2026-09-16T00:00:00', '2026-09-16T00:00Z', + '2026-09-16T00:00:00Z\n', '2026-09-16T00:00:00.Z', + '2026-02-29T00:00:00Z', '1900-02-29T00:00:00Z', '2100-02-29T00:00:00Z', + '2026-04-31T00:00:00Z', '2026-13-01T00:00:00Z', '2026-09-00T00:00:00Z', + '2026-09-32T00:00:00Z', '2026-09-16T24:00:00Z', '2026-09-16T00:60:00Z', + '2026-09-16T00:00:61Z', '2026-09-16T00:00:00+24:00', '2026-09-16T00:00:00+01:60', + ]; + for (const field of ['issued_at', 'source_as_of', 'review_due_at', 'valid_until']) { + for (const value of invalid) { + const packet = await packetFixture(); + packet.freshness[field] = value; + packet.integrity.envelope_sha256 = await decisionContextEnvelopeDigest(packet); + const result = await validateDecisionContextPacket(packet, { now: NOW }); + assert.equal(result.valid, false, `${field}: ${JSON.stringify(value)}`); + assert.equal(result.activeEligible, false); + assert.equal(result.trust.freshness, 'INVALID'); + assert.ok(result.errors.some((message) => message.includes(field))); + } + } +}); + +test('null is permitted only for nullable freshness boundaries', async () => { + for (const field of ['issued_at', 'source_as_of']) { + const packet = await packetFixture(); + packet.freshness[field] = null; + packet.integrity.envelope_sha256 = await decisionContextEnvelopeDigest(packet); + const result = await validateDecisionContextPacket(packet, { now: NOW }); + assert.equal(result.valid, false); + assert.equal(result.activeEligible, false); + assert.equal(result.trust.freshness, 'INVALID'); + assert.ok(result.errors.some((message) => message.includes(field))); + } + for (const field of ['review_due_at', 'valid_until']) { + const packet = await packetFixture(); + packet.freshness[field] = null; + packet.integrity.envelope_sha256 = await decisionContextEnvelopeDigest(packet); + const result = await validateDecisionContextPacket(packet, { now: NOW }); + assert.equal(result.valid, true); + assert.equal(result.activeEligible, true); + assert.equal(result.trust.freshness, 'CURRENT'); + } +}); + +test('RFC 3339 offsets, lower-case separators, fractions, and leap-year dates remain valid', async () => { + const fields = ['issued_at', 'source_as_of', 'review_due_at', 'valid_until']; + for (const value of [ + '2026-09-14T00:00:00Z', '2026-09-14t00:00:00z', + '2026-09-14T00:00:00+00:00', '2026-09-14T00:00:00-00:00', + '2026-09-14T05:30:00+05:30', '2026-09-13T16:00:00-08:00', + '2026-09-14T23:59:00+23:59', '2026-09-14T00:00:00.123456Z', + '2000-02-29T00:00:00Z', '2024-02-29T00:00:00Z', + ]) { + const packet = await packetFixture(); + for (const field of fields) packet.freshness[field] = value; + packet.integrity.envelope_sha256 = await decisionContextEnvelopeDigest(packet); + const result = await validateDecisionContextPacket(packet, { now: new Date(value) }); + assert.equal(result.valid, true, value); + assert.equal(result.activeEligible, true); + assert.equal(result.trust.freshness, 'CURRENT'); + assert.deepEqual(result.errors, []); + } +}); + test('self-asserted authenticated origin is rejected without an external trust verifier', async () => { const packet = await packetFixture(); packet.origin.authentication_state = 'AUTHENTICATED';