Repository navigation
feat: standing grants: one tap for a repeating job, built like an API key (end date, rotating token, alarms) #110
Description
Why
A job that runs again and again (daily, weekly) needs the same secret each run. A tap for each run defeats the point of a timer. A "never ask" rule (DESIGN.md decision 3 B) works, but it is a config edit, it has no end date, and little in it tells a theft apart from a normal use.
#108 covers one run planned ahead. This issue covers a job that repeats: one tap, then the job runs on its schedule for a limited time, without taps.
Treat such a grant like an API key. Good practice for API keys and similar credentials, checked on 2026-10-27:
- Narrow scope; an end date that must be set; a maximum set by the owner; "pending" until approved. GitHub fine-grained tokens: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
- A prefix and a checksum, so a leaked token is easy to find. https://github.blog/security/application-security/behind-githubs-new-authentication-token-formats/
- Shown once; "last used"; to replace a key, use the new key first, check the old key's last use, then turn the old key off and delete it. AWS: https://docs.aws.amazon.com/IAM/latest/UserGuide/id-credentials-access-keys-update.html
- Limits on uses and on lifetime; bound to a network range. Vault AppRole: https://developer.hashicorp.com/vault/api-docs/auth/approle
- Rotate a token on each use; when an old token comes back, revoke. RFC 9700 section 4.14.2. Tie the token to the client's key (RFC 8705, RFC 9449), but this is weak when the attacker has the key too: RFC 9700 section 4.10.1. https://www.rfc-editor.org/rfc/rfc9700.html
- A key that just expires can stop a running service. Warn before the end and make renewal easy. Google: https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys
Idea (decide the design when the work starts)
- One tap makes it. The message shows: the client, the job name (claimed, until feat: job identity: tokens signed by a job runner, so rules and grants can name one job #109), the secrets, the schedule (for example "daily, window 03:00 to 05:00"), the uses per window (default 1), and the end date. Approve, Deny.
- Narrow. Only the named secrets, only from the named client, only inside the windows. A secret allows standing grants only when its rule says so (
standing = true; off by default). - An end date is a must. The longest end date is set in the config (default 90 days). A Telegram warning 7 days before the end. Renewal is one tap and makes a new grant, so both grants work for a short time.
Each window has a maximum length too (config; for example 8 hours, the longestwindowof section 8). - A grant token for the job. The broker gives the client a random token once, after the tap, with a prefix (for example
nkg_) and a checksum. The broker keeps only its hash. The token works only with the client's mTLS key. - The token rotates on each use. Each use returns a new token. The client saves the new token before it uses the secret, so a crash does not lose it. If an old token comes back, the broker pauses the grant and sends an alarm. To see that a token is old, the broker keeps the hashes of old tokens (or a counter) until the grant ends.
- One use per window (or the set number). A second request in the same window is denied, the grant is paused, and an alarm goes out. Outside the windows every request is denied, with no ask and with an alarm.
- A missed window raises an alarm. A window that ends with no use sends an alarm, not only a line in the summary. This is the only sign when someone uses the token and also stops the job.
- Visible. Each use sends the silent notice, as a "never ask" use does (section 12). If the notice cannot be sent, the use is denied, and these notices count toward the limits of 6 per client and 20 in all, in any 60 seconds. A weekly summary lists every standing grant with its last use and its end date. From the phone: list, pause, revoke.
- Audit log. Make, use, rotate, the alarm on an old token or a second use, pause, revoke, end.
- One grant core. feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 (one run planned ahead), this issue, and the one-use pre-approval of a login (feat: website logins from a shared Bitwarden collection, with TOTP codes made on the broker #103 step 9) are the same thing with other limits: a scope, a start, an end and a number of uses. Build one core, with one store, one set of audit events and one revoke path. The scope is a secret, an SSH key (key, host key, remote user) or a login (item, origin).
The honest limit
This finds a theft. It does not stop it. Anything on the client that can read the job's token can use it inside a window, before the job. Then the real job is denied, and the owner gets an alarm and a paused grant. But the secret is already out.
The same user can also write the job's token file. An attacker can use the token and put the new token into the file, so the old-token check sees nothing. The "one use per window" rule still catches it when the job runs in that window. An attacker who uses the token and also stops the job is seen only by the missed-window alarm (step 7). To stop a theft, the job must run where the agents cannot read its token (decision 3 A, or #109 with a runner the agents cannot reach).
A grant for a website login (#103) gives the whole account to that job, the TOTP code too. Allow it only for items marked for it.
Open questions
- Schedule format: cron, or a list of daily or weekly windows. Proposed: daily and weekly windows only, in broker local time, or UTC.
- One flag or two. feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 uses
preapprove = true, this issuestanding = true. With one grant core, should a secret have one flag for all grant kinds? - Where the token lives on the client, and that only the job reads it. A file with mode 0600 is still readable by every process of the same user.
- A token lost on the client (disk, restore from a backup with an old token, a lost answer, a retry): the old token is "reused" and pauses the grant. Proposed: that is right; renewal is one tap.
- Broker restart: standing grants must survive a restart, unlike the grants of a window tap. Saved state on the broker, checked against the audit log. This changes section 8 ("grants live only in memory"), and feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 must match.
- A Deny. Section 8 says a Deny ends the client's grants. Does a Deny of another request end or pause a standing grant? Proposed: no; only Revoke and Pause do, and the message says so.
Done when
- DESIGN.md has standing grants and the shared grant core, with feat: scheduled grants: approve now, use later, with an end time and a number of uses #108 and feat: website logins from a shared Bitwarden collection, with TOTP codes made on the broker #103 step 9 as kinds of it. THREAT-MODEL.md has a standing grant used by something else on the client.
- A standing grant works only from its client, for its secrets, inside its windows, the set number of times per window. A test for each.
- An old token, a second use in one window, a request outside the windows, and a missed window each send an alarm; the first two pause the grant. Tests with the fake Telegram server.
- The broker keeps only a hash of the token. The token has a prefix and a checksum.
- The end date is a must. The warning before the end works. Renewal makes a new grant.
- List, pause and revoke from the phone. The weekly summary.
-
Standing grants survive a broker restart.Moved to feat: standing and scheduled grants survive a broker restart, rebuilt from the audit log #111 (owner answer A of 2026-10-29). - It gets the security review, as every change does.
Related: #108 (one run planned ahead), #109 (job identity), #103 (logins, step 9 one-use pre-approval), #104 (agent-browser plugin).
Activity
groupsky commentedon Oct 27, 2026
Grants that survive a broker restart: #111 (for later). It answers the restart open question here.
groupsky commentedon Oct 29, 2026
Design plan for #110 (plan only, no code yet)
I read this issue, #108 and its closing comment, #111, DESIGN.md section 8 ("Scheduled grants") and section 12, and the grant code in
internal/approval. Below: what is there now, the plan, my answer to each open question, and the questions that need the owner.What the #108 core gives us, and what it lacks
There is one
grantstruct (internal/approval/grants.go) and one end path (endWhere) for all end causes. The Revoke button and the message edits work through a queue, and they do not depend on the kind of grant. That is a good base.What standing grants need that is not there:
id != "".audit.GrantEndedaccepts only kindpreapprove.live()checks one[start, end). The one timer (watchGrants/nextEnd) wakes only at an end. Nothing wakes at the start or the end of a window.[preapprove] max_openhas a ceiling of 24 hours. A standing grant lives for weeks.endedwith uses left always sends the "not used" notice. That is wrong for a standing grant.checkPromptknows only the fields of kindpreapprove.The plan
1. Grant core: add a kind. Give the grant a
kindfield:window,scheduledorstanding. Replaceid != ""with it. Add an optional list of windows to the grant.live(now)becomes: before the end, and, if there are windows, inside one of them. Keep the use count per window: the start time of the current window, and the uses in it. The timer also wakes at each window end (needed for the missed-window alarm). Everything still ends throughendWhere, with new causes (below).2. Request: the same command, a new flag.
nodkey preapprovegets--daily 03:00-05:00and--weekly mon,thu 03:00-05:00(at most one of them, at most 7 windows). With one of these flags,--untilis a must,--usesmeans "uses per window" (default 1), and--fromis optional (default: now). Same endpoint,POST /v1/preapprove, with a new optionalwindowsfield and atoken_filegiven only to the client. The audit log writes kindstanding. One command keeps the docs and the client small; the audit kind keeps the two apart.3. Rule flag and limits. A new rule flag
standing = true, off by default, only foralwaysandwindowrules (aspreapprove). New config section[standing], with safe defaults. A config without it allows no standing grant:max_days(the end date)max_window(one window)max_uses(per window)max_grants(per client)warn_days(warning before the end)4. Token. After the tap, the broker makes a token:
nkg_+ 32 random bytes (base62) + a CRC32 checksum, as GitHub does. The broker keeps only its SHA-256 hash. The token goes back only in the answer tonodkey preapprove. The client writes it to the token file and never prints it. A token works only with the mTLS key of its client.5. Use and rotation.
nodkey run --grant-token FILE(and laternodkey ssh-agent, see question C) reads the token and sends it in a header. The broker:nowis inside a window, and the uses left in that window.grant_used(viastanding, with the hash of the new token) to disk.The client writes the new token to a temp file, syncs it to disk and renames it over the old file. Only then does it use the secrets. If the write fails, the client does not run the command.
6. Alarms. An alarm is a Telegram message with sound, and it is in the audit log (
grant_alarmwith a cause):--uses): denied, the grant ends, alarm.A request without a token goes the normal way (ask on the phone), as today. Alarms count toward the limit of 20 notices in all, and each grant sends at most one alarm per window.
7. Phone. The grant message gets a Revoke button, the same as in #108. Before the end (
warn_days), a warning comes with a Renew button (question D). Each week, a summary lists every standing grant: client, secrets, windows, last use, end date. It uses one more config key,summary = "mon 09:00", in broker local time. A/grantsbot command from the approver shows the same list now.8. Audit. Kind
standing.decisioncarriesnot_before,not_after, the windows and the uses per window.grant_usedgets the new token's hash. Newgrant_alarm. New end causes:alarmandrenewed. These fields are also what #111 needs to rebuild a grant after a restart.9. Docs. DESIGN.md section 8 gets "The grant core", with three kinds: window, scheduled and standing. #103 step 9 is written down as a later fourth kind. Section 12 points to standing grants as the better form of decision 3 B. Sections 9, 10, 14 and 15 get the new events, header, config and flags. THREAT-MODEL.md gets T36: a standing grant used by something else on the client, the token file, and the honest limit.
docs/setup.mdgets a timer example with its own system user, anddocs/manual-telegram-test.mdgets a new check.10. Two pull requests, as for #108:
/grants, docs.My answers to the open questions
hoursin section 12, so the two do not differ. A window that opens twice on the day the clocks go back still has one count. A window that falls into the skipped hour does not open; the missed-window alarm will show this. Run the broker in UTC to avoid it, as section 12 says.preapprove = trueandstanding = true. A standing grant gives a much wider right (weeks, no tap for each run). One flag for both would quietly widen every config that haspreapprove = truetoday.--grant-token FILE. The client refuses a file that other users can read or write, or that the user does not own. The docs say to run the job as its own system user (systemdUser=andStateDirectory=), so an agent that runs as another user cannot read it. The honest limit stays: the same user, or root, can read it. A systemd credential cannot be used, because the token changes on each use.Questions for the owner
nodkey update, every job needs a new tap and a new token.nodkey ssh-agentsigns many times, and each signature would rotate the token, which needs more client work.renewed. No command on the client is needed. Is that OK?groupsky commentedon Oct 29, 2026
Owner answers (2026-10-29) to the questions in the plan comment above:
later).renewed.The answers to open questions 1–6 in the plan stand as written.
docs: standing grants and the grant core (#110)
feat(config): the standing rule flag and the [standing] section (#110)
feat(approval): grant kinds and windows for standing grants (#110)
feat(grantkey): the standing grant token (#110)
feat: standing grant requests in nodkey preapprove (#110)
feat: nodkey run --grant-token (#110)
feat: nodkey run --grant-token (#110)
feat(broker): make standing grants (#110)
feat(audit): grant_alarm and new_token_hash entries (#110)
feat(broker): use and rotate standing grant tokens, with alarms (#110)
12 remaining items
feat: the weekly summary of standing grants (#110)
feat: the audit entry of a renewal of a standing grant (#110)
feat: the warning before the end of a standing grant, and Renew (#110)
feat: the Telegram warning with a Renew button (#110)
chore: gofmt and lint of the Renew code (#110)
docs: the warning and Renew of standing grants as built (#110)
docs: the setup guide and the manual check of standing grants (#110)
docs: the standing grant example takes one window flag (#110)
fix: one missed-window alarm per grant after a clock jump (#110)
test: a Renew of a standing grant through the broker (#110)
fix: a renewal that waits for its handover does not spin the timer (#110
test: wait for the end entry of a renewed grant (#110)
feat: standing grants (part 2 of #110) (#116)
groupsky commentedon Oct 29, 2026
Done. Part 1 is #115, part 2 is #116, both squash merged. Evidence for each "Done when" box:
/grants. THREAT-MODEL.md has T36 (a standing grant used by something else on the client, the token file, the honest limit). feat: standing grants (part 1 of #110) #115, feat: standing grants (part 2 of #110) #116.TestStandingForeignToken,TestStandingNotAllSecrets,TestStandingOutsideWindow,TestStandingBeforeStart,TestStandingUseRotates(uses per window),TestStandingClockSetBackGivesNoUses. feat: standing grants (part 1 of #110) #115.TestStandingOldToken, second use in one windowTestStandingUseRotates(over_uses), outside the windowsTestStandingOutsideWindow, unknown tokenTestStandingUnknownToken(feat: standing grants (part 1 of #110) #115, with the fake Telegram server ininternal/broker); missed windowTestMissedWindowAlarm,TestOneMissedAlarmAfterAWallClockJump(feat: standing grants (part 2 of #110) #116). The first two end the grant instead of a pause (owner answer B).internal/grantkey(nkg_prefix, CRC32 checksum;TestRoundTrip,TestParseRejects,TestNeverPrinted),TestStandingGrantKeepsOnlyTheHash. feat: standing grants (part 1 of #110) #115.TestStandingRefusals(no end date) feat: standing grants (part 1 of #110) #115;TestWarningOnceBeforeTheEnd,TestRenewHandsOverOnTheNextUse,TestRenewHandsOverAtTheOldEnd,TestOldTokenAfterRenewalIsAnAlarm,TestStandingRenew(through the broker),TestRenewalWaitingPastItsWarningDoesNotSpinfeat: standing grants (part 2 of #110) #116./grantsTestGrantsCommand,TestListStanding; RevokeTestStandingRevokeAndStop,TestRevokeOfARenewedGrantEndsTheRenewal; summaryTestWeeklySummary,TestSummary. No pause (owner answer B). feat: standing grants (part 1 of #110) #115, feat: standing grants (part 2 of #110) #116.TestStandingRevokeAndStop).Docs:
docs/setup.mdsection 11 "A job that runs every night: standing grants";docs/manual-telegram-test.mdcheck 15.Follow-up: standing grants for SSH keys is #128 (milestone
later, owner answer C).