Token expiresAt (TTL) is not enforced post-bind — sign-time ACL ignores it #24

Closed
opened 2026-06-18 21:38:56 +00:00 by padreug · 1 comment
Owner

Summary

A token's expiresAt (set via create_new_token durationInHours, lnbits #54) is enforced only at connect/redeem time. Once a client has connected and its per-KeyUser grants are materialized, an expired token keeps signing indefinitely — the sign-time ACL never looks at expiresAt. This is the same materialized-grants ACL-ordering subtlety as the token-revoke finding (aiolabs/spirekeeper#22): callers reasonably expect a TTL to bound a session's lifetime, but it bounds only the first connect.

Verified against dev @ cb8dd0c.

Where expiresAt is (and isn't) read

The only enforcement read is in validateToken, called from applyToken at redeem time:

src/daemon/backend/index.ts:94
  if (tokenRecord.expiresAt && tokenRecord.expiresAt < new Date()) throw new Error("Token expired");

At sign time (checkIfPubkeyAllowed, src/daemon/lib/acl/index.ts:23) expiresAt is never consulted:

  • Step 3b matches the materialized SigningCondition first (method='sign_event', kind=<n>, allowed=true, written by applyToken) and returns. SigningCondition has no expiry column at all (prisma/schema.prisma).
  • Step 4 (the live policy join) filters revokedAt: null only — not expiresAt (acl/index.ts:93-106).
  • No reaper sets revokedAt on expiry (the only setIntervals are interactive-authorization request cleanup, unrelated to Token).

So: redeem an expiring token → applyToken materializes the grants → the token lapses → checkIfPubkeyAllowed still returns true. The lapse is invisible to signing.

Impact

  • lnbits #54 "per-device token expiry" is connect-time-only as shipped — the advertised TTL does not limit an established binding's lifetime.
  • spirekeeper#23 exposes duration_hours on the pairing endpoint with the documented intent "rejects the token once it lapses, forcing a re-pair." That's only true for an un-redeemed token; the docstring is being corrected to say so. Consumers (bitspire#52, model A1 spire) must treat revoke (revoke_key_userKeyUser.revokedAt, step 2, beats everything) as the only post-bind cutoff — not TTL.

Options

  1. Sign-time enforcement. checkIfPubkeyAllowed step 4 token join adds expiresAt: { equals: null } OR { gt: now }; and the step-3b materialized SigningCondition path needs an expiry (either copy expiresAt onto the conditions applyToken writes, or skip the override layer for policy-derived grants so expiry-bearing tokens are always re-checked at step 4). Note step 3b currently short-circuits step 4, so a step-4-only fix is insufficient.
  2. Expiry reaper. A periodic job sets KeyUser.revokedAt (or deletes the materialized SigningConditions) when the binding's token has lapsed — leans on the existing step-2 revoke path that already works.
  3. Document-only. Declare TTL connect-window-only by design; revoke is the session-lifecycle control. (Matches current behavior; lowest effort.)

Option 2 is the smallest change that gives real post-bind enforcement, reusing the revoke path that's already correct.

Cross-refs

  • aiolabs/spirekeeper#22 — sibling finding: token-revoke is a post-redeem no-op for the same reason (materialized grants ACL-checked before the Token filter); revoke_key_user is the fix.
  • aiolabs/bitspire#52 — consumer (spire model A1); producer-side analysis comment with the full trace.
  • aiolabs/nsecbunkerd#11 — the ACL algorithm this builds on.
## Summary A token's `expiresAt` (set via `create_new_token` `durationInHours`, lnbits #54) is enforced **only at connect/redeem time**. Once a client has connected and its per-`KeyUser` grants are materialized, an **expired token keeps signing indefinitely** — the sign-time ACL never looks at `expiresAt`. This is the same materialized-grants ACL-ordering subtlety as the token-revoke finding (aiolabs/spirekeeper#22): callers reasonably expect a TTL to bound a session's lifetime, but it bounds only the *first connect*. Verified against `dev` @ `cb8dd0c`. ## Where `expiresAt` is (and isn't) read The **only** enforcement read is in `validateToken`, called from `applyToken` at redeem time: ``` src/daemon/backend/index.ts:94 if (tokenRecord.expiresAt && tokenRecord.expiresAt < new Date()) throw new Error("Token expired"); ``` At **sign time** (`checkIfPubkeyAllowed`, `src/daemon/lib/acl/index.ts:23`) `expiresAt` is never consulted: - **Step 3b** matches the materialized `SigningCondition` first (`method='sign_event', kind=<n>, allowed=true`, written by `applyToken`) and returns. `SigningCondition` has **no expiry column at all** (`prisma/schema.prisma`). - **Step 4** (the live policy join) filters `revokedAt: null` only — **not `expiresAt`** (`acl/index.ts:93-106`). - No reaper sets `revokedAt` on expiry (the only `setInterval`s are interactive-authorization request cleanup, unrelated to `Token`). So: redeem an expiring token → `applyToken` materializes the grants → the token lapses → `checkIfPubkeyAllowed` still returns `true`. The lapse is invisible to signing. ## Impact - **lnbits #54 "per-device token expiry" is connect-time-only as shipped** — the advertised TTL does not limit an established binding's lifetime. - **spirekeeper#23** exposes `duration_hours` on the pairing endpoint with the documented intent "rejects the token once it lapses, forcing a re-pair." That's only true for an un-redeemed token; the docstring is being corrected to say so. Consumers (bitspire#52, model A1 spire) must treat **revoke (`revoke_key_user` → `KeyUser.revokedAt`, step 2, beats everything) as the only post-bind cutoff** — not TTL. ## Options 1. **Sign-time enforcement.** `checkIfPubkeyAllowed` step 4 token join adds `expiresAt: { equals: null }` OR `{ gt: now }`; **and** the step-3b materialized `SigningCondition` path needs an expiry (either copy `expiresAt` onto the conditions `applyToken` writes, or skip the override layer for policy-derived grants so expiry-bearing tokens are always re-checked at step 4). Note step 3b currently short-circuits step 4, so a step-4-only fix is insufficient. 2. **Expiry reaper.** A periodic job sets `KeyUser.revokedAt` (or deletes the materialized SigningConditions) when the binding's token has lapsed — leans on the existing step-2 revoke path that already works. 3. **Document-only.** Declare TTL connect-window-only by design; revoke is the session-lifecycle control. (Matches current behavior; lowest effort.) Option 2 is the smallest change that gives real post-bind enforcement, reusing the revoke path that's already correct. ## Cross-refs - aiolabs/spirekeeper#22 — sibling finding: token-*revoke* is a post-redeem no-op for the same reason (materialized grants ACL-checked before the `Token` filter); `revoke_key_user` is the fix. - aiolabs/bitspire#52 — consumer (spire model A1); producer-side analysis comment with the full trace. - aiolabs/nsecbunkerd#11 — the ACL algorithm this builds on.
Author
Owner

Fixed by #27 (merge 992c6a8), deployed to all servers 2026-06-19.

Sign-time ACL now enforces Token.expiresAt (and revoke) live: checkIfPubkeyAllowed step 4 filters the token join through liveWhere(now), and Backend.applyToken no longer materializes the SigningCondition photocopy that used to short-circuit the check. Redeem and sign share one grantIsLive predicate, so they can't drift again.

Closing as delivered + deployed.

Fixed by #27 (merge `992c6a8`), **deployed to all servers 2026-06-19**. Sign-time ACL now enforces `Token.expiresAt` (and revoke) live: `checkIfPubkeyAllowed` step 4 filters the token join through `liveWhere(now)`, and `Backend.applyToken` no longer materializes the `SigningCondition` photocopy that used to short-circuit the check. Redeem and sign share one `grantIsLive` predicate, so they can't drift again. Closing as delivered + deployed.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd#24
No description provided.