Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,12 @@ except on a message whose request may still wait: there it is refused
as a changed message, with the alert. The alert still comes from the
other taps.

The warning before the end of a standing grant (section 8) has one
**Renew** button. Its data is `n:` and the ID of the request that made
the grant. A Renew gives a right, so it passes every check of an Approve,
also the check of a changed message. A Renew on a warning that is already
the message of the new grant is refused as a late tap.

A notice of a "never ask" use (section 12) has no buttons and no expiry.
A notice of a use of a scheduled grant is the same, with the second line
`allowed by a scheduled grant, without a tap`. It goes to the notice chat
Expand Down Expand Up @@ -990,7 +996,12 @@ Not masked, for example:
internet. The next call confirms the updates of the call before, so
Telegram forgets them. After a crash, Telegram sends the updates that
were not confirmed again; their taps find no waiting request.
- Only `callback_query` updates are read. Text sent to the bot is ignored.
- The broker asks only for `callback_query` and `message` updates. A
`message` is read only for the command `/grants` (section 8): the text
`/grants`, or `/grants@` and a name, with no space inside, from the
approver in the private chat with the approver, at most 3 answers in
any 60 seconds. Every other text sent to the bot is ignored, without an
answer.
- If `getUpdates` fails with a conflict (another program polls with the same
token, or a webhook was set), the broker logs an alert and sends a
message: the bot token may be stolen. Then the Telegram code stops
Expand Down Expand Up @@ -1487,16 +1498,14 @@ A config without `[standing]` allows no standing grant. The message is
that of a scheduled grant, with the windows, the uses per window and the
end date, and the buttons **Approve** and **Deny**. It says that a Deny of
other requests does not end the grant. The reason is the claimed job name.
For `nodkey preapprove --secret backup-token --daily 03:00-05:00 --weekly
mon,thu 10:30-11:00 --until 2026-12-31 --reason "job nightly-backup"` it
For `nodkey preapprove --secret backup-token --daily 03:00-05:00 --until 2026-12-31 --reason "job nightly-backup"` it
is:

```
🗓 Standing grant · request m4k2
✅ machine: laptop
✅ secrets: backup-token
✅ window: daily 03:00-05:00
✅ window: mon,thu 10:30-11:00
✅ uses in each window: 1
✅ from: 2026-10-28 03:00:00 UTC
✅ until: 2026-12-31 00:00:00 UTC
Expand Down Expand Up @@ -1565,12 +1574,43 @@ normal way, with a tap.

**On the phone.** The message of the grant has a **Revoke** button, as in
a scheduled grant. Within `warn_days` of the end, a warning comes with a
**Renew** button. A tap on Renew, after the full checks of section 7, makes
a new grant (new ID, new end, the same scope). The next use of the old
**Renew** button. The warning is sent `warn_days` before the end (default
7), once per grant, to the private chat with the approver, with sound. A
grant that is not longer than `warn_days` gets no warning. A warning that
cannot be sent is logged and not sent again. A tap on Renew, after the
full checks of section 7 (as for an Approve: the approver, the private
chat, the button data, the warning message as the broker sent it, and in
the core the grant of the request ID with this very warning), makes a new
grant: a new ID, a new request ID, a new end (the same length as the old
grant, counted from now, and not more than `max_days`), and the same
scope. The new grant takes the place of the old one: the two count as one
for `max_grants`, and a grant can be renewed once. Its `decision` entry,
with answer `renew` and `renew_of` (the old grant's ID), is on disk
before the new grant exists; if it cannot be written, the Renew does not
count. The warning message then becomes the message of the new grant, with
its Revoke button. The new grant has no token. The next use of the old
token gets the token of the new grant, and the old grant ends with cause
`renewed`. Once a week (`summary`) a message lists each standing grant: the
client, the secrets, the windows, the last use and the end date. The bot
command `/grants` from the approver shows the same list.
`renewed`. If the old grant reaches its end first, it ends with cause
`renewed` then, and its token passes to the new grant. The old tokens pass
too, so an old token is still an alarm. If the old grant ends in another
way before that (Revoke, an alarm, a cancel, a stop), the new grant ends
with it, with the same cause: it has no token of its own. Until the
handover, the list and `/grants` show the old grant only. Once a week (`summary`, broker local
time) a message lists each standing grant: the client, the secrets, the
windows, the last use and the end date. The bot command `/grants` from the
approver shows the same list. The list never shows a token or a hash.
With no standing grant, the weekly message says so, and `/grants` does too.
For `/grants` the bot also asks Telegram for `message` updates. Only the
text `/grants` (or `/grants@` and a name) from the approver in the
private chat with the approver gets an answer, at most 3 in a minute. A message from anyone or anywhere
else is ignored without an answer.

**Missed window.** When a window of a live grant ends with no use in it,
the broker sends the alarm `missed_window`. The grant stays. A window that
ended before the grant started does not count. A window in the hour that a
clock change skips does not open, so it also gives this alarm. Each grant
sends at most one alarm in a window. If the wall clock jumps ahead over
many windows, the grant sends one alarm for all of them.

**End.** A standing grant ends when its end date comes, on Revoke, on an
alarm that ends it, on a renewal, on a cancel by its own client (`nodkey
Expand Down Expand Up @@ -1649,7 +1689,7 @@ The fields of each event, after `seq`, `time`, `event` and `prev`:
| `broker_started` | `version` |
| `broker_stopped` | none |
| `request` | the request fields; `message`: the exact text the approver saw, if a message was sent |
| `decision` | the request fields; `user_id`: the Telegram user that answered; `answer`: `approve_once`, `approve_window` or `deny`; `window_minutes` for `approve_window` |
| `decision` | the request fields; `user_id`: the Telegram user that answered; `answer`: `approve_once`, `approve_window`, `deny` or `renew`; `window_minutes` for `approve_window`; for `renew`, the fields of the new standing grant (a new `request_id` and `grant_id`, `not_before` the time of the tap) and `renew_of`, the ID of the grant it renews |
| `grant_used` | the request fields; `via`: `grant`, `never_rule`, `scheduled` or `standing` (then `grant_id` is in the request fields); for `standing` also `new_token_hash` |
| `grant_ended` | the request fields of the `preapprove` request that made the grant, with its `grant_id`; `cause`: `used_up`, `ended`, `cancelled`, `denied`, `revoked`, `stopped`, `alarm` or `renewed`; `uses_left` |
| `grant_alarm` | the request fields; `grant_id` (none for an unknown token); `cause`: `old_token`, `over_uses`, `outside_window`, `unknown_token` or `missed_window` |
Expand Down
21 changes: 20 additions & 1 deletion THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,17 @@ Risk that remains: none known.
**T5. Someone else presses the button.**
The broker accepts only the configured Telegram user ID, in the private
chat with that user.
Risk that remains: see T7.
The bot also reads text messages, for the command `/grants` only
(DESIGN.md section 8). It answers only the text `/grants` (or `/grants@`
and a name) from the approver, in the private chat with the
approver, at most 3 times in any 60 seconds, and only in that chat. The
answer lists the standing grants: the machine, the secrets, the windows,
the last use and the end. It never has a token or a hash. Any other text,
and any text from another user or chat, is ignored without an answer, so
nobody else learns of the grants, and nobody can make the bot send many
messages. A command can approve, renew or end nothing.
Risk that remains: see T7. Someone who can use the approver's account
can read the list.

**T6. The bot token is stolen.**
With the token, an attacker can read updates before the broker does, send
Expand Down Expand Up @@ -1061,6 +1071,15 @@ each use. The token is in a file on the client.
list of all standing grants on the phone.
- A Deny of another request does not end a standing grant, so a Deny
cannot be used to break the job. The person ends it with **Revoke**.
- **Renew** gives a right, so a Renew tap has every check of an Approve
(T6): the approver's user ID, the private chat, strict button data, a
warning message that nobody changed, and in the core the grant of the
request ID with that very warning. Its `decision` entry is on disk before
the new grant exists. The new grant has no token: only the next use of
the old grant's token, which the job holds, gets one. So a forged Renew
cannot give a new token to anyone who does not hold the old one. The old
tokens pass to the new grant, so a stolen old token is still an alarm,
and an alarm or Revoke of the old grant also ends a renewal that waits.
- Standing grants live in memory and are dropped at a broker stop. The
stop notice lists them (T35).

Expand Down
64 changes: 64 additions & 0 deletions docs/manual-telegram-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,65 @@ Expected:
scheduled grant(s) ...`, with the grant ID, `test`, `demo-always` and
the uses left. The broker still stops within a few seconds.

### Check 15: Standing grants (optional)

This checks a standing grant: a use with the token file, the alarm of an
old token, `/grants` and Revoke. In `$T/broker.toml`, add `standing =
true` to the section of `demo-always`, add an empty `[standing]`
section, and start the broker again. Then, terminal 2, with `START` and
`END` as a window that starts in 2 minutes and ends 10 minutes later
(for example `14:05` and `14:15`, in the local time of the broker), and
`UNTIL` as tomorrow (for example `2026-10-30T00:00`):

```sh
HOME="$T/home" "$T/nodkey" preapprove --secret demo-always \
--daily START-END --until UNTIL \
--grant-token "$T/home/token" --reason "check 15"
```

1. Tap **Approve**. Then `ls -l "$T/home/token"`.
2. Send `/grants` to the bot, in your private chat with it.
3. Save a copy of the token, and wait until `START`:
`cp -p "$T/home/token" "$T/home/token.old"`. Then use the grant:

```sh
HOME="$T/home" "$T/nodkey" run --grant-token "$T/home/token" \
--secret DEMO_TOKEN=demo-always --reason "check 15, use" -- \
sh -c 'echo "the command got ${#DEMO_TOKEN} characters"'
echo "exit code: $?"
cmp "$T/home/token" "$T/home/token.old"
```

4. Use the old copy: the same `nodkey run` command, with
`--grant-token "$T/home/token.old"`.
5. Do step 1 again, with `--grant-token "$T/home/token2"`. Tap
**Revoke**. Send `/grants` again.
6. Remove the token files: `rm -f "$T/home/token" "$T/home/token.old"
"$T/home/token2"`.

Expected:

- Step 1: the message `🗓 Standing grant · request XXXX` has the lines
`✅ window: daily START-END`, `✅ uses in each window: 1` and `A Deny of
other requests does not end this grant. Use Revoke to end it.`. After
the tap it has `👍 approved by you at ...` and one **Revoke** button.
Terminal 2 prints the ID of the grant, and no token. The token file
has mode `-rw-------`.
- Step 2: a message `📋 Standing grants · 1` comes, with `machine: test`,
`secrets: demo-always`, `windows: daily START-END`, `last use: never`
and the end. It has no token.
- Step 3: no tap. Terminal 2 prints `the command got 15 characters` and
`exit code: 0`. A silent notice comes: `🔔 Standing grant · request
...` with `allowed by a standing grant, without a tap`. `cmp` says
that the files differ: the token was changed.
- Step 4: exit code 77, and no tap. An alarm comes, with sound: `🚨
Standing grant alarm · request ...` with `an old token was used again;
it may be stolen. The grant is ended.` and the grant ID. The message
of the grant has no button, and a line `🚨 ended by an alarm at ...`.
- Step 5: the phone shows `Revoked.`. The message has no button, and a
line `⛔ revoked by you at ...`. `/grants` answers `No standing
grants.`.

## Clean up

1. Terminal 1: stop the broker with Ctrl-C, if it still runs.
Expand Down Expand Up @@ -621,3 +680,8 @@ raw answer of the Bot API, and the lines of the log with `user_id` or
| 14 | Revoke on a scheduled grant | `Revoked.`; button gone; `revoked by you` | | |
| 14 | grant ends with uses left | `ended at ... with 2 of 2 use(s) left`; silent notice `not used` | | |
| 14 | stop with a scheduled grant | `dropped: the broker stopped`; one notice that lists the grant | | |
| 15 | a standing grant | Approve; the ID, no token; token file `-rw-------`; one **Revoke** button | | |
| 15 | `/grants` | the list with `test`, `demo-always`, the window; no token; after Revoke `No standing grants.` | | |
| 15 | a use with the token file | no tap; exit 0; silent notice; the token file changed | | |
| 15 | the old token again | exit 77; alarm with sound; `ended by an alarm` | | |
| 15 | Revoke on a standing grant | `Revoked.`; button gone; `revoked by you` | | |
Loading
Loading