From f3169a6004a67b1d7bb749e083f18052e7155463 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Fri, 17 Jul 2026 07:50:30 +0200 Subject: [PATCH] fix: floor the seal policy timestamp so it is never in the future MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PKG rejects a USK request whose timestamp is > now ("chronology error"). The seal timestamp was stamped with Math.round(Date.now()/1000), which can land up to ~1s ahead of the real time, so an encrypt→decrypt within the same second could fail. In normal usage the multi-second gap masks it; the e2e harness's headless flow (encryptionall/postguard-e2e) completes in <1s and surfaced it. Extract nowSeconds() (floor) and use it for both seal timestamps. Adds unit tests pinning the floor behavior. --- src/crypto/encrypt.ts | 5 +++-- src/util/policy.ts | 11 +++++++++++ tests/now-seconds.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/now-seconds.test.ts diff --git a/src/crypto/encrypt.ts b/src/crypto/encrypt.ts index 17bdc57..6068602 100644 --- a/src/crypto/encrypt.ts +++ b/src/crypto/encrypt.ts @@ -7,6 +7,7 @@ import { DEFAULT_EMAIL_ATTRIBUTES, type EmailAttributes } from '../util/attribut import { resolveSigningKeys } from './signing.js'; import Chunker, { withTransform } from './chunker.js'; import { createZipReadable } from '../util/zip.js'; +import { nowSeconds } from '../util/policy.js'; import { loadWasm } from '../util/wasm.js'; import type { RetryOptions } from '../util/retry.js'; @@ -59,7 +60,7 @@ export async function encryptPipeline(options: EncryptPipelineOptions): Promise< ]); // Build encryption policy - const ts = Math.round(Date.now() / 1000); + const ts = nowSeconds(); const policy = buildEncryptionPolicy(recipients, ts, emailAttrs); // If the sign method requests including the sender, add a sender entry @@ -186,7 +187,7 @@ export async function sealRaw(options: SealRawOptions): Promise { ]); // Build encryption policy - const ts = Math.round(Date.now() / 1000); + const ts = nowSeconds(); const policy = buildEncryptionPolicy(recipients, ts, emailAttrs); // Include sender in policy if requested diff --git a/src/util/policy.ts b/src/util/policy.ts index e26a223..9336672 100644 --- a/src/util/policy.ts +++ b/src/util/policy.ts @@ -3,6 +3,17 @@ export function sortPolicies(con: { t: string; v?: string }[]): { t: string; v?: return [...con].sort((a, b) => a.t.localeCompare(b.t)); } +/** Current Unix time in whole seconds. + * + * Uses `floor`, never `round`: this stamps the policy timestamp a sealed + * container is keyed on, and the PKG rejects a USK request whose timestamp is + * in the future ("chronology error"). `Math.round` can land up to ~1s ahead of + * the real time, so an immediate decrypt (encrypt→decrypt within the same + * second) could fail; `floor` is always ≤ now. */ +export function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + /** Calculate seconds until 4 AM (PKG key validity period) */ export function secondsTill4AM(): number { const now = Date.now(); diff --git a/tests/now-seconds.test.ts b/tests/now-seconds.test.ts new file mode 100644 index 0000000..ee3641f --- /dev/null +++ b/tests/now-seconds.test.ts @@ -0,0 +1,29 @@ +// The seal policy timestamp must never be in the future: the PKG rejects a USK +// request whose timestamp is > now ("chronology error"), so an immediate +// encrypt→decrypt could fail if the timestamp were rounded up. nowSeconds() +// uses floor for exactly this reason. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { nowSeconds } from '../src/util/policy.js'; + +describe('nowSeconds', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('floors — never rounds a sub-second past 0.5 up to the next second', () => { + // 1000.700s: Math.round would give 1001 (one second in the future). + vi.setSystemTime(new Date(1000_700)); + expect(nowSeconds()).toBe(1000); + }); + + it('is exact on a whole second', () => { + vi.setSystemTime(new Date(1000_000)); + expect(nowSeconds()).toBe(1000); + }); + + it('never exceeds Date.now()/1000 (would trip the PKG chronology check)', () => { + for (const ms of [1234_001, 1234_499, 1234_500, 1234_999]) { + vi.setSystemTime(new Date(ms)); + expect(nowSeconds()).toBeLessThanOrEqual(Date.now() / 1000); + } + }); +});