From d912307bb753f283a73bba2caddfabc6bdac7c22 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 25 Oct 2026 21:42:18 +0100 Subject: [PATCH] =?UTF-8?q?R-1280:=20a=20signed=20job's=20validity=20windo?= =?UTF-8?q?w=20is=20at=20most=2024=20h=20=E2=80=94=20the=20verifier,=20the?= =?UTF-8?q?=20root=20wrapper=20and=20felhom-opsign.=20Red-proved.=20Commit?= =?UTF-8?q?=20only,=20not=20released.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ cmd/felhom-opsign/main.go | 4 ++++ configs/felhom-os-apply | 4 ++++ configs/test_felhom_os_apply.py | 8 ++++++++ internal/authz/errors.go | 2 ++ internal/authz/verifier.go | 8 ++++++++ internal/authz/verify_test.go | 20 ++++++++++++++++++++ 7 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e2361..b34eb42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2026-10-25 (night, unattended) — a signed job's window is at most 24 h (R-1280, D44 #22) — committed, NOT released (rides the next agent release; the vouch is the operator's) + +- `internal/authz/verifier.go`: `MaxWindow = 24h`; a blob whose `expires_at - issued_at` is longer is refused + (`ErrWindowTooLong`) before the expiry and nonce checks — a job signed with a long window by mistake no longer stays + usable that long, and the nonce store no longer keeps its record that long. +- `configs/felhom-os-apply`: the root wrapper holds the same 24 h (`MAX_WINDOW_S`, refusal R3 „the signed job's window + is longer than 24 h"). +- `cmd/felhom-opsign`: `-ttl` above 24 h (or ≤ 0) is refused before anything is signed. +- Tests: `TestVerify_RejectsPerStage/window_longer_than_24_h` and `…/window_of_exactly_24_h_is_accepted`; + `test_felhom_os_apply.DockerLane.test_a_window_longer_than_24h_is_refused` / `…_of_24h_is_accepted`. Both refusals + seen failing with their check switched off: felhom.eu `documentation/audits/night-2026-10-25/redproof-r1280.txt`. + ## 2026-10-25 (evening, attended) — the `published` gate follows D50 = A: the newest 5 plus the pinned MinAgent and day-0 agent (R-1290; no binary change) - **D50 = A** (operator, 2026-10-25): the registry keeps the newest 5 released agent versions, the hub's MinAgent floor and diff --git a/cmd/felhom-opsign/main.go b/cmd/felhom-opsign/main.go index b6f1303..fe95a6e 100644 --- a/cmd/felhom-opsign/main.go +++ b/cmd/felhom-opsign/main.go @@ -123,6 +123,10 @@ func run() error { now := time.Now().UTC() issued := now + if *ttl <= 0 || *ttl > authz.MaxWindow { + fmt.Fprintf(os.Stderr, "felhom-opsign: -ttl %s refused: a box accepts a window of at most %s (R-1280)\n", *ttl, authz.MaxWindow) + os.Exit(2) + } expires := now.Add(*ttl) // Canonical OpBlob bytes — the EXACT bytes the agent verifier authenticates over. diff --git a/configs/felhom-os-apply b/configs/felhom-os-apply index 6e75462..86666b5 100755 --- a/configs/felhom-os-apply +++ b/configs/felhom-os-apply @@ -187,6 +187,8 @@ BUNDLE_PREV_DIR = "/var/lib/felhom-os-apply/bundle-prev" # the previous copies BUNDLE_KEEP = 3 BUNDLE_MAX_BYTES = 4 * 1024 * 1024 BUNDLE_RE = re.compile(r"^bundle-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\.json$") +# R-1280: the longest validity window a signed job may carry — the agent's authz.MaxWindow, the same 24 h. +MAX_WINDOW_S = 24 * 3600 OOB_DIR = "/etc/felhom-sshd" # R-982: the host's own sshd drop-in (after_install reloads sshd for it; undo reloads after putting it back). SSHD_DROPIN = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf" @@ -661,6 +663,8 @@ class Apply: iss = calendar.timegm(time.strptime(op["issued_at"], "%Y-%m-%dT%H:%M:%SZ")) except (KeyError, ValueError, TypeError): raise Refused("R3", "the signed job has no readable time window") + if exp - iss > MAX_WINDOW_S: + raise Refused("R3", "the signed job's window is longer than 24 h (R-1280)") if now > exp or now < iss - 120: raise Refused("R3", "the signed job is expired or not yet valid") nonce = op.get("nonce") diff --git a/configs/test_felhom_os_apply.py b/configs/test_felhom_os_apply.py index 671dc4a..df1aec1 100644 --- a/configs/test_felhom_os_apply.py +++ b/configs/test_felhom_os_apply.py @@ -833,6 +833,14 @@ class DockerLane(unittest.TestCase): def test_expired_signed_job_is_refused(self): self.refused(docker_fake(signed=signed_job(expires="2026-10-04T11:55:00Z")), "R3") + def test_a_window_longer_than_24h_is_refused(self): + # R-1280. RED-PROOF: drop the MAX_WINDOW_S check → the job (current, signed, 24 h + 1 s) runs. + self.refused(docker_fake(signed=signed_job(issued="2026-10-03T11:59:59Z", expires="2026-10-04T12:00:00Z")), "R3") + + def test_a_window_of_24h_is_accepted(self): + rc, rep = run(docker_fake(signed=signed_job(issued="2026-10-03T12:30:00Z", expires="2026-10-04T12:30:00Z"))) + self.assertNotEqual((rep.get("refused") or {}).get("code"), "R3", rep) + def test_replayed_signed_job_is_refused(self): f = docker_fake(signed=signed_job()) f.nonces = {"n1": f.clock + 600} diff --git a/internal/authz/errors.go b/internal/authz/errors.go index 9e727d5..24dd7ed 100644 --- a/internal/authz/errors.go +++ b/internal/authz/errors.go @@ -22,6 +22,8 @@ var ( ErrExpired = errors.New("authz: op expired") // ErrNotYetValid: now < issued_at (minus clock-skew tolerance). ErrNotYetValid = errors.New("authz: op not yet valid") + // ErrWindowTooLong: expires_at - issued_at > MaxWindow (R-1280). + ErrWindowTooLong = errors.New("authz: op validity window too long") // ErrReplay: the nonce was already recorded in the window. ErrReplay = errors.New("authz: replay (nonce already seen)") ) diff --git a/internal/authz/verifier.go b/internal/authz/verifier.go index b9773c4..5c57969 100644 --- a/internal/authz/verifier.go +++ b/internal/authz/verifier.go @@ -20,6 +20,11 @@ const Namespace = "felhom-op-v1" // the validity window stays an honest upper bound. const DefaultClockSkew = 2 * time.Minute +// MaxWindow is the longest validity window a signed op may carry (R-1280, D44 #22, operator 2026-10-25): a job signed +// with a long window by mistake would stay usable that long, and the nonce store would keep its record as long. The +// root wrapper (configs/felhom-os-apply, MAX_WINDOW_S) and felhom-opsign (-ttl) hold the same 24 h. +const MaxWindow = 24 * time.Hour + // KeyRole tags a pinned operator key (doc 04 §3 two-key model). type KeyRole string @@ -150,6 +155,9 @@ func (v *Verifier) Verify(blob, sigArmored []byte) (*VerifiedOp, error) { if now.Before(op.IssuedAt.Add(-v.ClockSkew)) { return nil, fmt.Errorf("%w: issued_at=%s now=%s", ErrNotYetValid, op.IssuedAt, now) } + if w := op.ExpiresAt.Sub(op.IssuedAt); w > MaxWindow { + return nil, fmt.Errorf("%w: issued_at=%s expires_at=%s (%s, the most is %s)", ErrWindowTooLong, op.IssuedAt, op.ExpiresAt, w, MaxWindow) + } if now.After(op.ExpiresAt) { return nil, fmt.Errorf("%w: expires_at=%s now=%s", ErrExpired, op.ExpiresAt, now) } diff --git a/internal/authz/verify_test.go b/internal/authz/verify_test.go index 5f4422a..0eb0b0d 100644 --- a/internal/authz/verify_test.go +++ b/internal/authz/verify_test.go @@ -117,6 +117,26 @@ func TestVerify_RejectsPerStage(t *testing.T) { rejects(t, err, ErrTarget) }) + // R-1280: a window longer than 24 h is refused even while it is current; exactly 24 h is accepted. + // RED-PROOF: drop the MaxWindow check → the 24 h + 1 s case verifies. + t.Run("window longer than 24 h", func(t *testing.T) { + blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-win-1", "{}", + refNow.Add(-time.Minute), refNow.Add(-time.Minute).Add(MaxWindow+time.Second)) + sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign) + v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom")) + _, err := v.Verify(blob, sig) + rejects(t, err, ErrWindowTooLong) + }) + t.Run("window of exactly 24 h is accepted", func(t *testing.T) { + blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-win-2", "{}", + refNow.Add(-time.Minute), refNow.Add(-time.Minute).Add(MaxWindow)) + sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign) + v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom")) + if _, err := v.Verify(blob, sig); err != nil { + t.Fatalf("a 24 h window must verify: %v", err) + } + }) + t.Run("expired", func(t *testing.T) { blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-exp-1", "{}", refNow.Add(-2*time.Hour), refNow.Add(-time.Hour))