diff --git a/AGENTS.md b/AGENTS.md index 4d7f6f6..71585b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,9 @@ Planned. Each v1 issue adds its part. points to it. - `docs/manual-telegram-test.md` - the manual test with a real Telegram bot (issue #17), with a table for the results. + - `docs/manual-ntfy-test.md` - the manual test with a real ntfy + server, on Android and on an iPhone (issue #24), with a table for + the results. - `docs/releasing.md` - how releases are made and checked. - `test/e2e/` - the end-to-end test with `sshd` in a container (DESIGN.md section 17). Build tag `e2e`. The image of the container is @@ -100,7 +103,7 @@ that it makes. - **If a change alters** what a message shows, what the broker checks, or where a value goes, update DESIGN.md and THREAT-MODEL.md in the same pull request. If it changes the config, the example unit, a command, or a - line that `docs/setup.md` or `docs/manual-telegram-test.md` shows, + line that `docs/setup.md` or a `docs/manual-*-test.md` guide shows, update these guides too. - **Dependencies:** the standard library, `golang.org/x/crypto` and `github.com/BurntSushi/toml`. Anything else needs a reason in the pull diff --git a/DESIGN.md b/DESIGN.md index 05f78e6..a83f29a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -49,7 +49,7 @@ what it protects against, and what it does not. ## 3. Not in version 1 -- ntfy, a passkey web page, a phone app. +- A passkey web page, a phone app. - An HTTP proxy that injects credentials. - An MCP server. - Windows and macOS. Version 1 is Linux only. @@ -61,7 +61,7 @@ what it protects against, and what it does not. holds plain host keys, and the broker signs only with plain keys. - Keys in hardware (TPM or YubiKey) through an upstream agent. - Filtering secrets out of the command's output. -- More than one approver. +- More than one approver, and Telegram and ntfy on one broker at once. Each of these can become a GitHub issue later. @@ -74,7 +74,7 @@ Each of these can become a GitHub issue later. │ ├─ nodkey run ... -- cmd │ mTLS │ ├─ SSH private keys (memory) │ │ └─ ssh / git / ansible │ ─────────▶ │ ├─ runs bws / bw │ │ │ SSH_AUTH_SOCK │ │ ├─ audit log │ -│ ▼ │ │ └─ Telegram long polling ───┼──▶ Telegram ──▶ phone +│ ▼ │ │ └─ Telegram or ntfy ────────┼──▶ Telegram or ntfy ──▶ phone │ nodkey ssh-agent │ │ │ └────────────────────────────┘ └──────────────────────────────┘ ``` @@ -101,14 +101,16 @@ cannot use them: no flag, config key or variable turns them on (THREAT-MODEL.md, T38). Inside the broker, the approver and the stores sit behind small Go -interfaces. Version 1 has one approver (Telegram) and three stores (`bws`, -`bw`, `1password`). Others, such as ntfy or `sops`, can be added later -without changing the rest. +interfaces. There are two approvers, Telegram and ntfy (section 7), and +three stores (`bws`, `bw`, `1password`). A broker uses one approver: its +config has `[telegram]` or `[ntfy]` (section 14). Others, such as `sops`, +can be added later without changing the rest. Dependencies are kept small on purpose: the Go standard library, `golang.org/x/crypto` (`ssh`, `ssh/agent`) and one TOML parser. The Telegram client is our own small HTTP code. We do not link the Bitwarden -SDK; its license is not a standard open-source license. We run the `bws` +SDK; its license is not a standard open-source license. The ntfy client is +our own small HTTP code too. We run the `bws` and `bw` programs instead. The `1password` store is the one exception, and it is the owner's choice @@ -1207,6 +1209,108 @@ Not masked, for example: client gets one such notice in each start, rarely more than one, and at most one try a minute. +### ntfy details + +[ntfy](https://ntfy.sh) is an open-source push service that can be +self-hosted. A broker with `[ntfy]` in its config (section 14) uses it +in place of Telegram. The message shows the same lines as on Telegram, +as plain text: the first line is the title. The rules of the core stay: +the first valid answer wins, and a late or second answer changes +nothing. What differs: + +- **Two topics.** The broker publishes each message to the request + topic. The person's phone subscribes to it. The buttons answer on the + answer topic. Only the broker reads it. The two must differ. One access + token (`token_file`) publishes and reads. +- **The buttons.** A request gets the buttons that it allows, as on + Telegram: Approve once, Approve N min, Deny. Each is an ntfy `http` + action: the phone sends a POST to the answer topic of the same server, + with the body `{"request":"","action":"once","value":""}` + (the action is `once`, `window`, `deny` or `revoke`). The phone then + clears the notification. The broker reads the answer topic with a + long-lived subscription, so it needs no open port, and the phone never + connects to the broker. +- **The value.** Each button gets its own value: 16 random bytes from + `crypto/rand`, as base64url. It is only in the message and in the + broker's memory, never in the audit log or the journal. It is not the + request ID. ntfy does not say who pressed a button, so the value is the + only proof. An answer counts only when all of these are true: + - its body has exactly the form above, as Go's `json.Marshal` writes + it, with no other field and no attachment; + - its request waits for an answer, and its message has a button of + that action; + - its value is the value of that button, compared in constant time. + The value of the Deny button never approves, and the value of + another request never counts; + - the approval core takes it (section 7 above: the request, the time, + the action). + + After an answer that counted, or that came too late, the broker forgets + the values of that request. So the same answer sent again does nothing. + The broker keeps the values of at most 4,096 requests; then a new + request is denied. +- **Who answered.** ntfy cannot say. So the core takes an ntfy answer + without a user ID, and the `decision` entry has `user_id` 0 and + `channel`, `ntfy:` and the answer topic (section 9). Anyone who can read + the request topic can answer: whoever runs the server, and whoever may + read the topic (THREAT-MODEL.md). +- **Refused answers.** Anyone who may write to the answer topic can send + an answer, so an answer that does not count is only logged, never + written to the audit log (ntfy has no `foreign_tap` entry). At most 20 + such log lines go out in a minute, for refused answers, lines that are + not valid JSON and lines over 16 KiB together. The broker counts the + others, and logs how many it dropped. +- **A press too early.** A press can come before the server confirmed the + message. Then the broker logs it and does nothing, and the value stays. + But the phone has cleared the notification, so the request only + expires, unless the same answer comes again. +- **When a request ends,** the broker replaces its message: the same + sequence ID, the same lines, the result in place of the expiry (the same + results as on Telegram), and no buttons. A scheduled grant that started + keeps one Revoke button, with a new value. When the grant ends, the + message is replaced again, without buttons. +- **Priorities.** A request: 4 (high). A notice of a "never ask" use, the + message after its request ended, the notice about the version of a + client, and the notice about a newer release: 2 (low). An alert, and + the notice about a newer release that fixes a security problem: 5 + (urgent). +- **Size.** ntfy sends a message over 4,096 bytes as an attachment. The + broker never does: it shortens the claimed lines, as for Telegram, so + the text stays under 4,096 bytes and the whole JSON body under 7,168 + bytes (ntfy refuses a body over 8,192 bytes). If the message still does + not fit, the request is denied. +- **The subscription.** The broker reads `//json` + with the token. The first connection asks only for new messages. When + the connection ends, the broker connects again and asks for the + messages since the last one it read (or since its start), so no answer + is lost. It waits 1 second before that, then twice as long each time, + at most 30 seconds. The wait goes back to 1 second only after a message + or a keepalive came, so a server that closes each connection at once + does not get a new connection each second. ntfy sends a keepalive every + 45 seconds; after 2 minutes without a line, the broker connects again. +- **A refused token.** If the server answers the subscription with 401 + or 403, only a new token or new access rules help. Then no answer can + come any more, as when Telegram's polling stops for good (above), and + `nodkey broker` exits with code 77 (section 15). +- **Sending.** A message, an end, an alert or a notice about a release + goes out with at most 3 tries: after a network error, code 429 or a + server error, it waits 0.5 seconds, then twice as long, at most 4 + seconds, and never past the end of the request. A notice of a use gets + one try. Each try takes at most 15 seconds. A message can come twice + after a network error; only the message that the server confirmed has + the values that the broker keeps. At most 20 notices of uses go out in + any 60 seconds, as on Telegram. +- **The server.** It must use `https`. The client needs TLS 1.2 or newer, + follows no redirect, and uses the proxy from the environment, as the + Telegram code does. The token goes only in the `Authorization` header, + never in a URL. No error and no log line holds the token, a value, Go's + error text or the server's text. +- **No standing grants on ntfy.** The command `/grants`, the warning + before a standing grant ends, the weekly summary and the alarm of a + standing grant (section 8) are Telegram only. So a config with + `[ntfy]` and `[standing]` does not load: without the alarm, nobody + would see a misused grant. + ## 8. Rules and grants Each secret and each key has a rule: @@ -1718,7 +1822,9 @@ and the alarm comes. Run the job as its own system user (see request ID, the client, the names, the verified host if any, and the claimed command and reason (masked as in section 7). - The `request` entry holds the exact message text the approver saw. The - `decision` entry holds the Telegram user ID that answered. + `decision` entry holds the Telegram user ID that answered. With ntfy, + `user_id` is 0 and `channel` says where the answer came from (section + 7). - No entry ever holds a secret value, a signature, or the data that was signed. `cause` and `code` hold fixed codes, never an error message, because an error message can hold text from a store program or from the @@ -1752,13 +1858,13 @@ 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`, `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 | +| `decision` | the request fields; `user_id`: the Telegram user that answered, or 0 with ntfy; `channel`: only with ntfy, `ntfy:` and the answer topic, such as `ntfy:nodkey-answers`; `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` | | `secret_sent`, `signed` | the request fields | | `denied` | the request fields; `cause`: `approver`, `expired`, `cancelled`, `not_allowed`, `limit` or `error` | -| `foreign_tap` | `user_id`, `chat_id`, `message_id`, `data`: the button data, at most 64 bytes, and `cause`: why it did not count (section 7). An entry with cause `edited_message` can belong to a Deny that counted: its `data` is `d:` and the request ID, and that request has a `decision` entry with `deny` | +| `foreign_tap` | Telegram only. `user_id`, `chat_id`, `message_id`, `data`: the button data, at most 64 bytes, and `cause`: why it did not count (section 7). An entry with cause `edited_message` can belong to a Deny that counted: its `data` is `d:` and the request ID, and that request has a `decision` entry with `deny` | | `error` | the request fields, if the failure is about one request; `code`: `store_failed`, `sign_failed`, `approver_failed` or `poll_conflict` | | `log_recovered` | `torn_length` and `torn_sha256`: the length and the SHA-256 of the torn line | @@ -2542,6 +2648,15 @@ notice_chat_id = 123456789 # optional; default: the approver's chat timeout = "120s" # secrets ssh_timeout = "90s" # SSH signatures +# Or, in place of [telegram] (never both): +# [ntfy] +# server = "https://ntfy.example.com" +# request_topic = "nodkey-requests" # the phone subscribes here +# answer_topic = "nodkey-answers" # the buttons answer here; only the broker reads it +# token_file = "${CREDENTIALS_DIRECTORY}/ntfy-token" +# timeout = "120s" +# ssh_timeout = "90s" + [updates] check = true # optional; false: never ask GitHub for a newer release @@ -2674,6 +2789,17 @@ More rules: Without it, the field is `"password"`. `field` is an error for a secret in a `bws` or `bw` store. The broker hands out the value of that one field. +- The config has exactly one approval channel: `[telegram]` or `[ntfy]`. + Both at once is an error. +- In `[ntfy]`, `server`, `request_topic`, `answer_topic` and `token_file` + are required. `server` starts with `https://` and has a host and an + optional path, but no user name, query or fragment. A topic is 1 to 64 + letters, digits, `_` or `-`, and the two topics differ. The token file + holds one access token: 8 to 256 printable ASCII characters without + space; space around it is fine. The token must be allowed to publish + to the request topic and to read the answer topic. `timeout` and + `ssh_timeout` are as in `[telegram]`. There is no `notice_chat_id`: + notices go to the request topic. - `clients` and `hosts` list each name once. `notice_chat_id` is not 0. A `window` is 1 minute to 8 hours. `timeout` is 15 s to 10 min. `ssh_timeout` is 15 s to 110 s, below @@ -2857,8 +2983,8 @@ it started before. the broker denies the requests for its secrets until the store works (each request tries again), and the approver gets one alert for all such stores, at step 9. -6. It makes the broker's service, the Telegram approver (it reads the bot - token) and the approval core. It checks the client pins. +6. It makes the broker's service, the approver of `[telegram]` or `[ntfy]` + (it reads the bot token or the access token) and the approval core. It checks the client pins. 7. It opens the `listen` address. Nothing reads from it yet: a client that connects waits. So an address that is not there yet, such as at boot before the private network is up, stops the start here: before @@ -2866,7 +2992,8 @@ it started before. broker again later, and no alert goes out each time. 8. It writes the `broker_started` entry. No later step of the start can fail. -9. It starts to poll Telegram, and sends the alerts that waited. It sends +9. It starts to poll Telegram, or the subscription to the ntfy answer + topic, and sends the alerts that waited. It sends them at the same time, each on its own, so a slow try of one alert does not delay the others. They can reach the phone in any order. After a power cut, the host can be up before Telegram can be reached. @@ -2906,7 +3033,8 @@ order: is still running is cancelled, also the dial of a lookup. After this step, it sends no request and no notice. 2. The polling: the `getUpdates` call that waits is cancelled and its - connection closed (section 7). From now on no answer counts: every + connection closed (section 7). With ntfy, the subscription is + cancelled. From now on no answer counts: every request that waits for the person is denied. The scheduled grants are dropped, with their `grant_ended` entries and the edits of their messages (together at most 5 seconds). Then the broker sends the stop @@ -2944,7 +3072,7 @@ kills it. | 1 | any other end: the start failed, the polling stopped for another reason (such as another refusal of `getUpdates` than 401, 404 or 409), the server failed, or the stop was not clean, such as when the `broker_stopped` entry could not be written | | 64 | wrong usage | | 76 | Telegram reported a polling conflict (section 7): the bot token may be stolen. Make a new bot token, then start the broker. | -| 77 | Telegram does not accept the bot token: `getUpdates` got 401 or 404, such as for a revoked token or a bot that is gone. Put a new token into the token file, then start the broker. | +| 77 | The approval channel does not accept the token: Telegram's `getUpdates` got 401 or 404, such as for a revoked token or a bot that is gone, or the ntfy server answered the subscription with 401 or 403. Put a new token into the token file, or fix the access rules of the server, then start the broker. | The example unit lists 64, 76 and 77 in `RestartPreventExitStatus=`: only a person can fix these, and a new start with the same setup would @@ -3640,7 +3768,14 @@ programs still hold in the same words where they make sense: restart, a message that someone else edited, with an Approve and a Deny on it, also after a flood of forged taps, polling conflict, code 429 and server errors, and a canary bot token that must not appear in - any error or log line). A child process checks the HTTP client with + any error or log line). The ntfy approver runs against a fake ntfy + server with tokens made by the test: a wrong value, the value of + another request or of another button, a late answer, a second answer, + a replayed answer, an answer that is not in the exact form, a flood of + bad answers and of long lines, a lost subscription, a server that + closes each connection at once, no keepalive, a refused token, the + size of the message and of the body, and a token that must not appear + in any error or log line. A child process checks the HTTP client with `GODEBUG=http2debug=2` and a proxy. The lookup of a newer release runs against a fake GitHub server: every kind of answer that is not valid, 403 and 429, a redirect, a time-out, an answer over 1 MiB, and, in a @@ -3666,6 +3801,9 @@ programs still hold in the same words where they make sense: order of the start and the stop, the exit codes, that core dumps are off, a store that does not start, the notice about a newer release, and a canary from the request to the fake Telegram server, end to end. + With `[ntfy]`, the command runs against a fake ntfy server: a press of + Approve once approves a request, and a refused token stops the broker + with code 77, without the token in the log. - **Fake 1Password**: the `1password` store reaches the SDK through a small interface, `opItems` (`internal/store/onepassword_test.go`). The tests use a fake behind it and check the deny paths: a missing item, an diff --git a/README.md b/README.md index 57b03da..b219eb9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Try it on a test setup first. See the **Approve N min**. It shows which machine asks, for which secret or key, and for SSH the real target host, checked by the broker. No answer means no. + In place of Telegram, the broker can use an ntfy server that you run + yourself ([docs/setup.md](docs/setup.md#ntfy-in-place-of-the-telegram-bot)). - Every request and decision goes into a hash-chained audit log. ## Try it @@ -228,6 +230,8 @@ check of the audit log stores, the Telegram bot and the clients. - [docs/manual-telegram-test.md](docs/manual-telegram-test.md): the manual test with a real Telegram bot. +- [docs/manual-ntfy-test.md](docs/manual-ntfy-test.md): the manual + test with a real ntfy server. - [DESIGN.md](DESIGN.md): how version 1 works. - [THREAT-MODEL.md](THREAT-MODEL.md): what nodkey protects against, and what it does not. Please read section 5 before you use nodkey. diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index b48b5c8..00d17e4 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -32,6 +32,8 @@ access to another machine and asks a person before each use. | the approver (the person) | Trusted. Their judgment is the security check. | | the approver's phone and Telegram account | Trusted. Anyone who can use this account can approve. So the account must not be logged in on any agent machine (see T7). | | Telegram's servers | Trusted to deliver messages and to say truthfully who pressed a button. They see the message text, never a secret value that nodkey fetched. | +| the ntfy server and its operator (only with `[ntfy]`) | Trusted to deliver messages and answers, and not to change them. Whoever runs the server can forge an answer, as Telegram could, and sees the message text, never a secret value that nodkey fetched. Use a server that you run yourself (see T40). | +| everyone who can read the ntfy request topic (only with `[ntfy]`) | Trusted as the approver. ntfy does not say who pressed a button, so anyone who can read the request topic can approve. So it must be read only on the approver's phone, never on an agent machine (see T40). | | the broker host | Fully trusted. Root on the broker host can read and change everything. | | the agent machine | **Not trusted.** The agent may be root there. | | the agent | **Not trusted.** It may make mistakes, lie, or follow injected instructions. | @@ -99,6 +101,10 @@ Read this part carefully. These are real limits. on the agent machine: the web app in a browser, the browser extension, the desktop app or the CLI (see T32). The same holds for everyone who can open the nodkey vault in 1Password's apps (see T39). +10. **With ntfy, anyone who can read the request topic can approve.** + That includes whoever runs the ntfy server, and an ntfy web app or + desktop client on the agent machine that subscribes to the topic (see + T40). ## 6. Threats and answers @@ -1233,6 +1239,77 @@ Risk that remains: real. nodkey cannot see a read in 1Password's own apps or with the token, and it cannot check where an account is logged in. A leaked token reads the whole vault until it expires or is revoked. +**T40. The ntfy server, or someone who can read the request topic, +answers a request.** +With `[ntfy]` in its config, the broker sends its messages through an +ntfy server, in place of Telegram (DESIGN.md section 7, "ntfy details"). +This channel is weaker than Telegram in four ways: +- **ntfy does not say who pressed a button.** Each button holds its own + random value, and the value is the only proof. So anyone who can read + the request topic can approve: whoever runs the server, and everyone + that the server lets read the topic. An ntfy web app or desktop client + on the agent machine that subscribes to the topic lets the agent + approve itself. This is T7, for ntfy. +- **The server can change a message, and the broker cannot see it.** + Telegram shows an edit, so the broker refuses an Approve on an edited + message (T6). ntfy shows nothing of the kind. A server that its + attacker controls can show other names on a waiting message, or swap + the labels of its buttons. +- **The server sees the text:** machine names, host names, secret names, + the claimed command and reason. This is T23, for the ntfy server. It + never sees a secret value that nodkey fetched. +- **A topic with no access rules is open to anyone who knows its name.** + On such a server, anyone who guesses the request topic can read the + messages and approve. + +What nodkey does: +- Each button of each request has its own value: 16 random bytes from + `crypto/rand`. It is only in the message and in the broker's memory, + never in the audit log or the journal, and it is not the request ID. An + answer counts only with the value of that very button of its own + request, compared in constant time. The value of the Deny button never + approves. +- The rules of the core stay (T4): the first valid answer wins, only + while the request waits and before it expires. After an answer that + counted, or that came too late, the broker forgets the values of that + request, so a second or replayed answer changes nothing. A restart + forgets all values. +- Anyone may write to the answer topic, so refused answers have a + budget: at most 20 log lines in a minute. They are never written to + the audit log, so a flood cannot fill the disk or the journal. +- The `decision` entry has `user_id` 0 and the `channel`: `ntfy:` and + the answer topic (DESIGN.md section 9). It does not claim a person. +- A config with `[ntfy]` and `[standing]` does not load (DESIGN.md + section 7). The alarm of a standing grant (T36) is Telegram only. +- The token goes only in the `Authorization` header, never in a URL or + a log line. The server must use `https`. + +What to do: +- Run the ntfy server yourself, with `auth-default-access: deny-all` and + access tokens. Then no third party can read the topic or forge an + answer. Give the broker's user only write access to the request topic + and read access to the answer topic, and the phone's user only read + access to the request topic. The buttons send no token, so anyone + (`everyone`) may only write to the answer topic. Give nobody else read + access to either topic: only the broker reads the answer topic. +- Never subscribe to the request topic on an agent machine: not in the + ntfy web app, not in a desktop client, not with `curl`. Never keep the + phone's ntfy login on an agent machine. +- For quick delivery on iOS, the server needs `upstream-base-url`. Then + the upstream server gets only the message ID and a hash of the topic + URL, never the text. +- Use topic names that are hard to guess, in case an access rule is + wrong. + +A stronger later step is the passkey page (issue #25). Then ntfy is only +the alert: a button opens the page, and a passkey on the phone signs the +request. Nobody in the middle can forge that. + +Risk that remains: real. Whoever runs the server, or can read the request +topic, can approve, and the server can change what a waiting message +shows. nodkey cannot see either. With a self-hosted server and strict +access rules, this is the owner of the server and the phone. + ## 7. Assumptions - The broker host is patched, has no agent on it, and only the owner has @@ -1254,6 +1331,9 @@ leaked token reads the whole vault until it expires or is revoked. `/usr/local/bin`. `nodkey update` runs, as root, the script that is built into that binary. - The approver's Telegram account is not logged in on any agent machine. +- With ntfy: the ntfy server is run by the owner, with + `auth-default-access: deny-all`, and nothing on an agent machine can + read the request topic. - No Bitwarden account that can read the secrets of the stores is logged in on any agent machine, and no agent machine can read its master password. diff --git a/cmd/nodkey/broker.go b/cmd/nodkey/broker.go index 8b8d846..75a6383 100644 --- a/cmd/nodkey/broker.go +++ b/cmd/nodkey/broker.go @@ -25,6 +25,7 @@ import ( "github.com/code6x/nodkey/internal/audit" "github.com/code6x/nodkey/internal/broker" "github.com/code6x/nodkey/internal/config" + "github.com/code6x/nodkey/internal/ntfy" "github.com/code6x/nodkey/internal/release" "github.com/code6x/nodkey/internal/store" "github.com/code6x/nodkey/internal/telegram" @@ -188,7 +189,7 @@ func serveBroker(ctx context.Context, path string, stderr io.Writer, log *slog.L log.Error("nodkey broker: stopped after a polling conflict; the bot token may be stolen, so systemd "+ "does not start the broker again", slog.Int("exit_code", code)) case exitTokenRefused: - log.Error("nodkey broker: stopped, because Telegram does not accept the bot token; put a new token "+ + log.Error("nodkey broker: stopped, because the approval channel does not accept the token; put a new token "+ "into the token file, then start the broker", slog.Int("exit_code", code)) default: log.Error("nodkey broker: stopped", slog.Int("exit_code", code)) @@ -205,7 +206,7 @@ func brokerExitCode(bySignal bool, runErr error, clean bool) int { switch { case errors.Is(runErr, telegram.ErrPollConflict): return exitPollConflict - case errors.Is(runErr, telegram.ErrTokenRefused): + case errors.Is(runErr, telegram.ErrTokenRefused), errors.Is(runErr, ntfy.ErrTokenRefused): return exitTokenRefused case bySignal && clean: return exitOK @@ -226,7 +227,7 @@ type brokerParts struct { audit *audit.Logger stores map[string]store.Store svc *broker.Service - tg *telegram.Approver + ch *channel // alerts are the alert messages that wait until the approver can send // them: a torn line in the audit log, and stores that did not start. @@ -328,13 +329,15 @@ func (b *brokerParts) start(ctx context.Context, path string) error { return err } b.svc = svc - // The approval core takes the approver, so Telegram comes first. - tg, err := telegram.New(cfg.Telegram, telegram.Options{Logger: b.log, ForeignTap: svc.ForeignTap}) + // The approval core takes the approver, so the channel (Telegram or + // ntfy) comes first. + ch, err := newChannel(cfg, b.log, svc.ForeignTap) if err != nil { return err } - b.tg = tg - core, err := approval.New(cfg, tg, approval.Options{Logger: b.log}) + b.ch = ch + approver := ch.approver + core, err := approval.New(cfg, approver, approval.Options{Logger: b.log}) if err != nil { return err } @@ -369,8 +372,8 @@ func (b *brokerParts) start(ctx context.Context, path string) error { } b.started = true b.startRun(core) - b.sendAlerts(tg.Alert) - b.startCheck(tg.Inform, tg.InformLoud) + b.sendAlerts(ch.alert) + b.startCheck(ch.inform, ch.loud) b.serve(srv, ln) b.log.Info("nodkey broker: listening; the broker is ready", slog.String("address", ln.Addr().String())) return nil @@ -462,9 +465,9 @@ func (b *brokerParts) startRun(core *approval.Core) { "every request is denied, and the broker stops") } }() - b.runErr = b.svc.Run(ctx, core, b.tg.Alert, b.tg.Inform) + b.runErr = b.svc.Run(ctx, core, b.ch.alert, b.ch.inform) }() - b.log.Info("nodkey broker: the approver runs: it polls Telegram for the answers") + b.log.Info("nodkey broker: the approver runs: it waits for the answers") } // startCheck starts the check for a newer release (DESIGN.md section 15): diff --git a/cmd/nodkey/brokerchannel.go b/cmd/nodkey/brokerchannel.go new file mode 100644 index 0000000..d455d65 --- /dev/null +++ b/cmd/nodkey/brokerchannel.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "log/slog" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/audit" + "github.com/code6x/nodkey/internal/broker" + "github.com/code6x/nodkey/internal/config" + "github.com/code6x/nodkey/internal/ntfy" + "github.com/code6x/nodkey/internal/telegram" +) + +// channel is the approval channel of a running broker: Telegram or ntfy, +// by the section that the config has (DESIGN.md section 7). It takes the +// place of Telegram in the start and stop order of section 15. +// +// approver is not embedded: the core looks for the optional parts of the +// approver (grant lists, alarms, warnings) in the value it gets, so it must +// get the approver itself. +type channel struct { + approver approval.Approver + alert broker.AlertFunc + inform broker.NoticeFunc + loud broker.NoticeFunc +} + +// newChannel makes the approver of the channel that cfg has. foreignTap is +// for Telegram only: ntfy answers carry no sender to record. +func newChannel(cfg *config.Broker, log *slog.Logger, foreignTap func(audit.ForeignTap)) (*channel, error) { + if cfg.Ntfy == nil { + tg, err := telegram.New(cfg.Telegram, telegram.Options{Logger: log, ForeignTap: foreignTap}) + if err != nil { + return nil, err + } + return &channel{approver: tg, alert: tg.Alert, inform: tg.Inform, loud: tg.InformLoud}, nil + } + n := cfg.Ntfy + data, err := config.ReadPrivateFile(n.TokenFile) + if err != nil { + return nil, fmt.Errorf("ntfy: cannot read the access token: %w", err) // names the path, never the content + } + token, err := ntfy.NewToken(string(data)) + clear(data) + if err != nil { + return nil, err + } + ap, err := ntfy.New(ntfy.Config{Server: n.Server, RequestTopic: n.RequestTopic, AnswerTopic: n.AnswerTopic, + PublishToken: token, ReadToken: token}, ntfy.Options{Logger: log}) + if err != nil { + return nil, err + } + // Notices go out with a low priority; alerts and the notice about a + // security release with a high one, as on Telegram. + return &channel{approver: ap, alert: ap.Alert, inform: ap.Inform, loud: ap.Alert}, nil +} diff --git a/cmd/nodkey/brokerenv_linux_test.go b/cmd/nodkey/brokerenv_linux_test.go index a8819da..788e848 100644 --- a/cmd/nodkey/brokerenv_linux_test.go +++ b/cmd/nodkey/brokerenv_linux_test.go @@ -336,6 +336,9 @@ type brokerOptions struct { updatesOff bool // standing adds [standing] and standing = true for github-token. standing bool + // ntfy, if set, gives the broker [ntfy] with this fake server instead + // of [telegram]. + ntfy *fakeNtfy } // brokerEnv is nodkey broker in a child process, with its files and the @@ -360,6 +363,9 @@ type brokerEnv struct { // proxyUser is the user name in HTTPS_PROXY of the last start: a new // one for each start (see fakeTelegram.proxiedBy). proxyUser string + // caFile, if set, is the file of CA certificates the child trusts + // instead of those of the fake Telegram. + caFile string } // newBrokerEnv makes the files of a broker in a new private directory, @@ -505,6 +511,9 @@ hosts = ["db-1"] if opts.updatesOff { cfg += "\n[updates]\ncheck = false\n" } + if opts.ntfy != nil { + cfg = opts.ntfy.useIn(e, cfg) + } writeTestFile(t, e.cfgPath, []byte(cfg), 0o600) return e } @@ -668,13 +677,17 @@ func (e *brokerEnv) start(seams ...string) { // Upper-case letters and digits: nothing to escape in the URL. e.proxyUser = rand.Text() cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestBrokerChild$") //nolint:gosec // G204: the test runs its own binary again. + caFile := e.tg.caFile + if e.caFile != "" { + caFile = e.caFile + } cmd.Env = []string{ brokerChildConfig + "=" + e.cfgPath, brokerChildSeams + "=" + strings.Join(seams, ","), brokerChildProxy + "=" + e.tg.proxyAddr, "CREDENTIALS_DIRECTORY=" + filepath.Join(e.dir, "creds"), "HTTPS_PROXY=http://" + e.proxyUser + "@" + e.tg.proxyAddr, - "SSL_CERT_FILE=" + e.tg.caFile, + "SSL_CERT_FILE=" + caFile, "SSL_CERT_DIR=" + e.tg.caDir, } for _, name := range []string{"GORACE", "GOCOVERDIR", "TMPDIR"} { diff --git a/cmd/nodkey/brokerntfy_linux_test.go b/cmd/nodkey/brokerntfy_linux_test.go new file mode 100644 index 0000000..5067b20 --- /dev/null +++ b/cmd/nodkey/brokerntfy_linux_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/code6x/nodkey/internal/api" +) + +// fakeNtfy is the smallest ntfy server that the broker needs: publish as +// JSON on "/", a POST of text to the answer topic, and its JSON stream. +// The token is made at run time. +type fakeNtfy struct { + t *testing.T + srv *httptest.Server + token string // the token that publish and read accept; guarded by mu + + mu sync.Mutex + published []ntfyMessage + subs []chan []byte + nextID int +} + +type ntfyMessage struct { + Topic string `json:"topic"` + Title string `json:"title"` + Message string `json:"message"` + Actions []struct { + Label string `json:"label"` + URL string `json:"url"` + Method string `json:"method"` + Body string `json:"body"` + } `json:"actions"` +} + +const ( + ntfyRequests = "fake-requests" + ntfyAnswers = "fake-answers" +) + +func newFakeNtfy(t *testing.T) *fakeNtfy { + t.Helper() + b := make([]byte, 15) + if _, err := rand.Read(b); err != nil { + t.Fatal(err) + } + f := &fakeNtfy{t: t, token: "tk_" + hex.EncodeToString(b)} + f.srv = httptest.NewTLSServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.srv.Close) + return f +} + +// useIn puts a token file and a CA file into e, and returns cfg with +// [ntfy] in place of [telegram]. The token file holds token, which is not +// the token of the server when the caller changed f.token. +func (f *fakeNtfy) useIn(e *brokerEnv, cfg string) string { + creds := filepath.Join(e.dir, "creds") + writeTestFile(f.t, filepath.Join(creds, "ntfy-token"), []byte(f.token+"\n"), 0o600) + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.srv.Certificate().Raw}) + e.caFile = filepath.Join(e.dir, "ntfy-ca.pem") + writeTestFile(f.t, e.caFile, ca, 0o600) + i, j := strings.Index(cfg, "[telegram]"), strings.Index(cfg, "[client.laptop]") + section := fmt.Sprintf("[ntfy]\nserver = %q\nrequest_topic = %q\nanswer_topic = %q\ntoken_file = %q\n\n", + f.srv.URL, ntfyRequests, ntfyAnswers, "${CREDENTIALS_DIRECTORY}/ntfy-token") + return cfg[:i] + section + cfg[j:] +} + +func (f *fakeNtfy) serve(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + token := f.token + f.mu.Unlock() + if r.Header.Get("Authorization") != "Bearer "+token { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/": + var m ntfyMessage + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&m); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + f.mu.Lock() + f.published = append(f.published, m) + f.nextID++ + id := fmt.Sprintf("m%011d", f.nextID) + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "time": time.Now().Unix(), "event": "message", "topic": m.Topic}) + case r.Method == http.MethodPost && r.URL.Path == "/"+ntfyAnswers: + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + f.answer(string(body)) + _, _ = io.WriteString(w, `{"id":"x","event":"message"}`) + case r.Method == http.MethodGet && r.URL.Path == "/"+ntfyAnswers+"/json": + f.stream(w, r) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (f *fakeNtfy) answer(body string) { + f.mu.Lock() + defer f.mu.Unlock() + f.nextID++ + line, _ := json.Marshal(map[string]any{"id": fmt.Sprintf("m%011d", f.nextID), "time": time.Now().Unix(), + "event": "message", "topic": ntfyAnswers, "message": body}) + for _, c := range f.subs { + c <- append(line, '\n') + } +} + +func (f *fakeNtfy) stream(w http.ResponseWriter, r *http.Request) { + c := make(chan []byte, 64) + f.mu.Lock() + f.subs = append(f.subs, c) + f.mu.Unlock() + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = fmt.Fprintf(w, `{"id":"open1","time":%d,"event":"open","topic":%q}`+"\n", time.Now().Unix(), ntfyAnswers) + w.(http.Flusher).Flush() + for { + select { + case <-r.Context().Done(): + return + case l := <-c: + _, _ = w.Write(l) + w.(http.Flusher).Flush() + } + } +} + +// waitRequest waits until a published message has a button with label, +// and returns it. +func (f *fakeNtfy) waitRequest(label string) ntfyMessage { + f.t.Helper() + for end := time.Now().Add(brokerWait); time.Now().Before(end); time.Sleep(10 * time.Millisecond) { + f.mu.Lock() + for _, m := range f.published { + for _, a := range m.Actions { + if a.Label == label { + f.mu.Unlock() + return m + } + } + } + f.mu.Unlock() + } + f.t.Fatalf("no request with the button %q was published", label) + return ntfyMessage{} +} + +// press presses the button with label, as the phone does. +func (f *fakeNtfy) press(m ntfyMessage, label string) { + f.t.Helper() + for _, a := range m.Actions { + if a.Label != label { + continue + } + req, err := http.NewRequestWithContext(context.Background(), a.Method, a.URL, bytes.NewReader([]byte(a.Body))) + if err != nil { + f.t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+f.token) + resp, err := f.srv.Client().Do(req) + if err != nil { + f.t.Fatal(err) + } + _ = resp.Body.Close() + return + } + f.t.Fatalf("no button %q", label) +} + +// TestBrokerNtfyApproves: with [ntfy] in the config, a request for a +// secret reaches the ntfy server as a message with buttons, and a press +// of "Approve once" approves it. The broker stops clean with SIGTERM. +func TestBrokerNtfyApproves(t *testing.T) { + t.Parallel() + f := newFakeNtfy(t) + e := startListening(t, brokerOptions{ntfy: f}) + c := e.httpClient() + res := make(chan brokerAnswer, 1) + go func() { res <- e.post(c, api.PathSecrets, secretsRequest("GH_TOKEN", "github-token")) }() + m := f.waitRequest("Approve once") + // A press can come before the broker knows the message ID; the + // answer then does nothing, and the person presses again. + var a brokerAnswer +loop: + for end := time.Now().Add(2 * brokerWait); time.Now().Before(end); { + f.press(m, "Approve once") + select { + case a = <-res: + break loop + case <-time.After(100 * time.Millisecond): + } + } + mustStatus(t, a, http.StatusOK) + if got := secretValues(t, a); got["GH_TOKEN"] != e.values["github-token"] { + t.Errorf("the secret is not the one that the store holds") + } + if strings.Contains(e.stderr.text(), f.token) { + t.Error("the log of the broker has the ntfy token") + } + e.signal(syscall.SIGTERM) + e.mustExit(exitOK) +} + +// TestBrokerNtfyRefused: when the server refuses the token on the answer +// topic (403), the broker stops with exit code 77, as for a refused +// Telegram token, and the log does not show the token. +func TestBrokerNtfyRefused(t *testing.T) { + t.Parallel() + f := newFakeNtfy(t) + e := newBrokerEnv(t, brokerOptions{ntfy: f}) + // The server knows another token than the file. + f.mu.Lock() + f.token += "x" + f.mu.Unlock() + e.start() + e.mustExit(exitTokenRefused) + if strings.Contains(e.stderr.text(), f.token) { + t.Error("the log of the broker has the ntfy token") + } +} diff --git a/cmd/nodkey/try_test.go b/cmd/nodkey/try_test.go index ed5fb96..d9f2d7c 100644 --- a/cmd/nodkey/try_test.go +++ b/cmd/nodkey/try_test.go @@ -32,7 +32,7 @@ var tryOnly = map[string]bool{ // nodkey broker has no way to turn on the approval in the terminal, or // the test store: no file outside of nodkey try uses their names, main.go // reaches nodkey try only by its command, and the one approval core of -// nodkey broker takes the Telegram approver. +// nodkey broker takes the approver of the config's channel (Telegram or ntfy). func TestBrokerHasNoTerminalApproval(t *testing.T) { files, err := filepath.Glob("*.go") if err != nil { @@ -60,8 +60,8 @@ func TestBrokerHasNoTerminalApproval(t *testing.T) { } if pkg, isIdent := sel.X.(*ast.Ident); isIdent && pkg.Name == "approval" && sel.Sel.Name == "New" { cores++ - if arg, ok := n.Args[1].(*ast.Ident); path != "broker.go" || !ok || arg.Name != "tg" { - t.Errorf("%s makes an approval core with another approver than Telegram", path) + if arg, ok := n.Args[1].(*ast.Ident); path != "broker.go" || !ok || arg.Name != "approver" { + t.Errorf("%s makes an approval core with another approver than the one of the broker's channel", path) } } } diff --git a/contrib/systemd/nodkey.service b/contrib/systemd/nodkey.service index db3af60..3bc4cea 100644 --- a/contrib/systemd/nodkey.service +++ b/contrib/systemd/nodkey.service @@ -25,7 +25,7 @@ Description=nodkey broker: secrets and SSH signatures after an approval on the phone Documentation=https://github.com/code6x/nodkey/blob/main/DESIGN.md # The broker listens on one address of the private network, and calls -# Telegram. If that address is not there yet, the start fails, and systemd +# Telegram or ntfy. If that address is not there yet, the start fails, and systemd # tries again (Restart= below). Wants=network-online.target After=network-online.target @@ -41,6 +41,7 @@ ExecStart=/usr/local/bin/nodkey broker --config /etc/nodkey/broker.toml # One line for each file of the config; remove the lines you do not need. LoadCredentialEncrypted=broker-tls-key:/etc/nodkey/credentials/broker-tls-key.cred LoadCredentialEncrypted=telegram-token:/etc/nodkey/credentials/telegram-token.cred +LoadCredentialEncrypted=ntfy-token:/etc/nodkey/credentials/ntfy-token.cred LoadCredentialEncrypted=bws-token:/etc/nodkey/credentials/bws-token.cred LoadCredentialEncrypted=bws-token-backup:/etc/nodkey/credentials/bws-token-backup.cred LoadCredentialEncrypted=bw-master-password:/etc/nodkey/credentials/bw-master-password.cred @@ -58,12 +59,13 @@ UMask=0077 # A crash or an error starts the broker again, after 5 seconds. Not after # these exit codes, which only a person can fix (DESIGN.md section 15): # 64 wrong usage: check ExecStart=. -# 76 a polling conflict at Telegram: another program polls with the bot +# 76 (Telegram only) a polling conflict at Telegram: another program polls with the bot # token, so the token may be stolen. Make a new token first; starting # again would only send the same alert again. -# 77 Telegram does not accept the bot token, such as a revoked token. +# 77 Telegram does not accept the bot token, or the ntfy server refuses +# the access token (401 or 403), such as a revoked token. # Put a new token into its credential first; starting again would only -# call Telegram with the old token every 5 seconds. +# call the server with the old token every 5 seconds. Restart=on-failure RestartSec=5s RestartPreventExitStatus=64 76 77 diff --git a/docs/manual-ntfy-test.md b/docs/manual-ntfy-test.md new file mode 100644 index 0000000..5c38620 --- /dev/null +++ b/docs/manual-ntfy-test.md @@ -0,0 +1,432 @@ +# Manual test with a real ntfy server + +The tests of nodkey use a fake ntfy server. It cannot prove that the real +server and the real phone apps behave the same. This test runs a +throwaway broker with a self-hosted ntfy server, and you tap the buttons +on an Android phone and on an iPhone. It is the manual test of issue +[#24](https://github.com/code6x/nodkey/issues/24). Write the results into +that issue. + +What the test checks: + +| issue #24 asks | checks | +|---|---| +| Messages reach Android and iOS, with a self-hosted server and the upstream. | [1](#check-1-delivery) | +| Approve once, Approve N min and Deny work, as `http` actions. | [2](#check-2-approve-once), [3](#check-3-approve-n-min), [4](#check-4-deny) | +| The message is replaced (same sequence ID) when a request ends. | [2](#check-2-approve-once) to [5](#check-5-expiry) | +| A late or second answer changes nothing. | [6](#check-6-second-tap), [7](#check-7-late-tap-after-a-restart), [8](#check-8-a-forged-answer) | +| Revoke on a scheduled grant. | [9](#check-9-revoke-on-a-scheduled-grant) | +| Silent notices use a low priority, alerts a high one. | [10](#check-10-priorities) | + +[DESIGN.md section 7, "ntfy details"](../DESIGN.md#ntfy-details) says +what the broker does in each case. The docs of ntfy do not confirm the +`http` action and message updates on iOS, so check 1 to 5 matter most +on the iPhone. + +## Rules + +- Use two new topics and a new user for this test. Never use the + topics, users or token of a real broker. +- The token goes into one file with mode `0600`, outside of every + repository. Never paste it into a chat with a person or an AI + assistant, an issue, a commit or a command line. +- Tap only on the phones. The test machine never reads the request + topic. +- Remove the users and the access rules of the test at the end. + +## What you need + +- A self-hosted ntfy server with `https`, with these lines in its + `server.yml`, and a root shell on it for `ntfy user`, `ntfy access` and + `ntfy token`: + + ```yaml + auth-file: "/var/lib/ntfy/user.db" + auth-default-access: "deny-all" + upstream-base-url: "https://ntfy.sh" + ``` + + The examples use `https://ntfy.example.com`. +- An Android phone and an iPhone, each with the ntfy app. +- A Linux machine with the nodkey source at the commit that you test, + Go 1.26 or newer, `git`, `curl` and `jq`. It does not need systemd. +- Three terminals on that machine: **terminal 1** runs the broker, + **terminal 2** runs the client, and **terminal 3** is for the rest. + +## Set up + +### 1. Users and access rules + +On the ntfy server: + +```sh +ntfy user add nodkey-test-broker # asks for a password +ntfy user add nodkey-test-phone +ntfy access nodkey-test-broker nodkey-test-requests write-only +ntfy access nodkey-test-broker nodkey-test-answers read-only +ntfy access nodkey-test-phone nodkey-test-requests read-only +ntfy access everyone nodkey-test-answers write-only +ntfy token add --label="nodkey manual test" nodkey-test-broker +``` + +The last command prints the token of the broker. + +### 2. Save the token + +In terminal 3: + +```sh +T=~/.config/nodkey-test +install -d -m 0700 "$T" +read -rs TOKEN # paste the token, then press Enter; it is not shown +(umask 077; printf '%s\n' "$TOKEN" > "$T/ntfy-token") +unset TOKEN +ls -l "$T/ntfy-token" # must show -rw------- +``` + +Check the access rules, without a token: + +```sh +S=https://ntfy.example.com +curl -sS -o /dev/null -w '%{http_code}\n' "$S/nodkey-test-requests/json?poll=1" +curl -sS -o /dev/null -w '%{http_code}\n' "$S/nodkey-test-answers/json?poll=1" +``` + +Both must print `403`: nobody may read the topics without a login. + +### 3. The phones + +On each phone, in the settings of the ntfy app, add the user +`nodkey-test-phone` with its password for `https://ntfy.example.com`. +Then subscribe to `nodkey-test-requests` on that server. On Android, turn +on instant delivery for the subscription. Do not subscribe to +`nodkey-test-answers`. + +### 4. Build nodkey, and make the files of the broker + +Do [step 5 of the Telegram test](manual-telegram-test.md#5-build-nodkey-and-make-the-files-of-the-broker) +up to the config of the broker. Then use this config of the broker, +`$T/broker.toml`, with the pin of the client: + +```toml +listen = "127.0.0.1:7743" +tls_key = "~/.config/nodkey-test/broker.key" +audit_log = "~/.config/nodkey-test/state/audit.jsonl" +known_hosts = "~/.config/nodkey-test/known_hosts" + +[ntfy] +server = "https://ntfy.example.com" +request_topic = "nodkey-test-requests" +answer_topic = "nodkey-test-answers" +token_file = "~/.config/nodkey-test/ntfy-token" +timeout = "60s" + +[preapprove] + +[client.test] +pin = "sha256:...client..." # the pin of the client + +[store.fake] +type = "bws" +program = "~/.config/nodkey-test/bin/fake-bws" +token_file = "~/.config/nodkey-test/bws-token" + +[secret.demo-always] +store = "fake" +id = "11111111-1111-4111-8111-111111111111" +clients = ["test"] +ask = "always" +preapprove = true + +[secret.demo-window] +store = "fake" +id = "22222222-2222-4222-8222-222222222222" +clients = ["test"] +ask = "window" +window = "2m" + +[secret.demo-never] +store = "fake" +id = "22222222-2222-4222-8222-222222222222" +clients = ["test"] +ask = "never" +``` + +Make the config of the client, and make both files private, as the rest +of step 5 of the Telegram test says. + +### 5. Start the broker + +Do [step 6 of the Telegram test](manual-telegram-test.md#6-start-the-broker): +start the broker in terminal 1, and define `ask` in terminal 2. + +## The checks + +Do check 1 to 7 on the Android phone, then again on the iPhone. Both +phones get each message; tap on the one that you test. Write what you saw +into the [results table](#results). If something else happens, write it +down exactly, with the time, and keep `$T/broker.log`. + +In each message, check the title (`🔑 Secrets · request XXXX`), the lines +with ✅ (`machine: test`, `secrets:` and the name), the claimed lines +below them, and the expiry line. The short code in the title must be the +code that terminal 2 prints. + +### Check 1: Delivery + +1. Lock the phone that you test. +2. Terminal 2: `ask demo-always "check 1"`. + +Expected: + +- A notification comes, with sound, within a few seconds. Write down + how many seconds it took on each phone. +- It shows the lines above, and the buttons **Approve once** and + **Deny**. Leave it for check 2. + +### Check 2: Approve once + +1. Tap **Approve once** on the message of check 1. + +Expected: + +- Terminal 2 prints `the command got 15 characters` and `exit code: 0`. +- The notification goes away (the button has `clear: true`). +- The message in the app keeps its text, the expiry line becomes + `👍 approved once at HH:MM:SS`, and the buttons are gone. It is + the same message, not a second one. The update makes no sound. + +### Check 3: Approve N min + +1. Terminal 2: `ask demo-window "check 3"`. The message has three + buttons: **Approve once**, **Approve 2 min** and **Deny**. +2. Tap **Approve 2 min**. +3. At once, terminal 2: `ask demo-window "check 3, inside the grant"`. +4. Wait until 2 minutes after the tap. Then, terminal 2: + `ask demo-window "check 3, after the grant"`. Leave this message for + check 4. + +Expected: + +- Step 2: the command runs, and the message shows + `👍 approved for 2 min at HH:MM:SS`, without buttons. +- Step 3: no message. The command runs at once. +- Step 4: a new message with the three buttons. + +### Check 4: Deny + +1. Tap **Deny** on the message of check 3, step 4. +2. At once, terminal 2: `ask demo-window "check 4, inside the pause"`. + +Expected: + +- Step 1: terminal 2 prints `nodkey: denied (exit 77)`. The message + shows `⛔ denied at HH:MM:SS`, without buttons. +- Step 2: no message. Terminal 2 prints + `nodkey: denied by a limit of the broker (exit 77)` at once. + +### Check 5: Expiry + +1. Terminal 2: `ask demo-always "check 5"`. Do not tap. +2. Wait 60 seconds. + +Expected: terminal 2 prints `nodkey: denied (exit 77)`. The message +shows `⌛ expired — denied`, without buttons. + +### Check 6: Second tap + +1. Terminal 2: `ask demo-always "check 6"`. +2. Tap **Approve once** two times, as fast as you can. Or, after the + first tap, open the app and tap the button again on the message in + the list, if it still has buttons. If no second tap is possible, + write "not possible" into the results, with the app and its version. + +Expected: + +- The command runs once. +- If a second tap was possible: the log of the broker (terminal 1) has + `ntfy: refused an answer`, and the message still shows + `👍 approved once at HH:MM:SS`. + +### Check 7: Late tap after a restart + +1. Terminal 2: `ask demo-always "check 7"`. Do not tap. +2. Terminal 3, kill the broker hard, so that it cannot replace the + message: + + ```sh + pkill -KILL -f "nodkey broker --config $T/broker.toml" + ``` + + Terminal 1 prints `the broker exited with 137`. Terminal 2 prints an + error and exit code 69. +3. Terminal 1: start the broker again, with the same command as in + [step 5](#5-start-the-broker). +4. On the phone, tap **Approve once** on the old message. + +Expected: + +- Nothing runs. The log has `ntfy: refused an answer`, with + `why="no request with this ID waits for an answer here"`. +- The old message keeps its buttons: after a restart, the broker does + not know the message any more. Write down what the phone shows. + +### Check 8: A forged answer + +Anyone may write to the answer topic, so send an answer with a wrong +value, as an attacker could. + +1. Terminal 2: `ask demo-always "check 8"`. Do not tap. +2. Terminal 3, send **Approve once** with a made-up value: + + ```sh + ID=$(jq -r 'select(.event == "request") | .request_id' "$T/state/audit.jsonl" | tail -1) + curl -sS -o /dev/null -w '%{http_code}\n' \ + -d "{\"request\":\"$ID\",\"action\":\"once\",\"value\":\"AAAAAAAAAAAAAAAAAAAAAA\"}" \ + "$S/nodkey-test-answers" + ``` + +3. Tap **Deny** on the phone. + +Expected: + +- Step 2: curl prints `200`: anyone may write. Terminal 2 still waits. + The log has `ntfy: refused an answer`, with + `why="the value is not the value of this button of the request"`. +- Step 3: terminal 2 prints `nodkey: denied (exit 77)`. + +### Check 9: Revoke on a scheduled grant + +With `NOW` and `LATER` as times in the next 10 minutes (for example +`2026-11-02T14:05` and `2026-11-02T14:08`, in the local time of the +broker), terminal 2: + +```sh +HOME="$T/home" "$T/nodkey" preapprove --secret demo-always \ + --from NOW --until LATER --uses 2 --reason "check 9" +``` + +1. Tap **Approve**. Look at the message. +2. Tap **Revoke**. + +Expected: + +- Step 1: the message has the line `👍 approved at ...` and one + **Revoke** button. +- Step 2: the message has no button, and a line + `⛔ revoked at ..., with 2 use(s) left`. + +### Check 10: Priorities + +1. Terminal 2: `ask demo-never "check 10"`. A notice comes: + `🔔 Secrets · request XXXX` with + `allowed by a "never ask" rule, without a tap`. +2. Terminal 3, stop the broker cleanly, then cut the last line of the + audit log in half, as a power cut could: + + ```sh + pkill -TERM -f "nodkey broker --config $T/broker.toml" + printf '{"seq":' >> "$T/state/audit.jsonl" + ``` + + Terminal 1 prints `the broker exited with 0`. Do not use Ctrl-C here: + in bash, it stops `tee` too. + +3. Terminal 1: start the broker again. An alert comes: + `The audit log ended in a torn line, as after a power cut...`. + +Expected, on both phones: + +- A request (checks 1 to 9) has priority 4 (high): sound, and on + Android a pop-up. +- The notice of step 1, and each message after its request ended, have + priority 2 (low): no sound, no pop-up. +- The alert of step 3 has priority 5 (urgent): sound, and the + strongest alert that the phone gives. + +Write down what each phone did. Do check 11 after this check, so that +`audit verify` sees the torn line and its `log_recovered` entry. + +### Check 11: The audit log + +Terminal 3: + +```sh +"$T/nodkey" audit verify "$T/state/audit.jsonl" --anchors "$T/broker.log" +jq -c '{seq, event, answer, cause, user_id, channel} | with_entries(select(.value != null))' "$T/state/audit.jsonl" +``` + +Expected: `audit verify` prints `ok:` and `anchors: ... checked, all +match`. Each `decision` entry has `user_id` 0 and +`channel` `ntfy:nodkey-test-answers`. There is no `foreign_tap` entry: +refused answers are only in the log of the broker. + +### Check 12: The token in the logs + +Terminal 3: + +```sh +grep -cF -f "$T/ntfy-token" "$T/broker.log" "$T/state/audit.jsonl" +``` + +Expected: it prints `0` for both files. + +### Check 13: A token that the server refuses (optional) + +1. Terminal 1: start the broker again, if it does not run. +2. On the ntfy server: `ntfy token remove nodkey-test-broker `. + Type the token on the server, not on the test machine. + +Expected: within about 2 minutes, the log has +`ntfy: the server refused the read token; no answers can come any more`, +and terminal 1 prints `the broker exited with 77`. + +## Clean up + +1. Terminal 1: stop the broker with Ctrl-C, if it still runs. +2. On the ntfy server: + + ```sh + ntfy access --reset nodkey-test-broker + ntfy access --reset nodkey-test-phone + ntfy access --reset everyone nodkey-test-answers + ntfy user del nodkey-test-broker + ntfy user del nodkey-test-phone + ``` + +3. Remove the files of the test, with the token: + `rm -rf ~/.config/nodkey-test`. +4. On each phone, remove the subscription and the user. + +## Results + +Copy this table into a comment on issue #24, and fill it in. The issue is +public. Leave out the token, the name of the server, and every raw +answer of the server. + +- Date: +- nodkey commit: +- ntfy server version: +- Android phone, Android version, ntfy app version: +- iPhone, iOS version, ntfy app version: + +| # | check | expected | Android | iPhone | notes | +|---|---|---|---|---|---| +| 1 | delivery, phone locked | a notification with sound; the seconds it took | | | | +| 2 | Approve once | the command runs; notification gone; the same message shows `approved once`, no buttons | | | | +| 3 | Approve 2 min | the command runs; `approved for 2 min`; no buttons | | | | +| 3 | inside the grant | no message; the command runs | | | | +| 3 | after the grant | a new message | | | | +| 4 | Deny | exit 77; `denied`; no buttons | | | | +| 4 | inside the pause | `denied by a limit of the broker`, exit 77 at once | | | | +| 5 | expiry | exit 77 after 60 s; `expired — denied`; no buttons | | | | +| 6 | second tap | the command runs once; `refused an answer`, or "not possible" | | | | +| 7 | late tap after a restart | nothing runs; `refused an answer`; what the phone shows | | | | +| 8 | forged answer | curl `200`; refused; the request still waits | | | | +| 9 | Revoke on a scheduled grant | button gone; `revoked` | | | | +| 10 | request priority | 4: sound, pop-up | | | | +| 10 | notice and ended message priority | 2: no sound | | | | +| 10 | alert priority | 5: sound | | | | +| 11 | `audit verify`; `user_id` 0, `channel` | ok, all anchors match | | | | +| 12 | the token in the logs | `0` for both files | | | | +| 13 | refused token (optional) | exit code 77 | | | | diff --git a/docs/setup.md b/docs/setup.md index bfb18b0..56468c1 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,8 +1,8 @@ # Set up nodkey This guide sets up nodkey from the start: the broker on a trusted -machine, a Telegram bot for the approvals, the secret stores, and the -client on the machine where the AI agent works. +machine, a Telegram bot (or an ntfy server) for the approvals, the +secret stores, and the client on the machine where the AI agent works. [DESIGN.md](../DESIGN.md) says how nodkey works, and [THREAT-MODEL.md](../THREAT-MODEL.md) says what it protects against. @@ -423,6 +423,110 @@ of your own. Only your taps count. group, send `/setjoingroups` to BotFather and choose **Disable**. Then nobody can add the bot to a group. +### ntfy in place of the Telegram bot + +The broker can send its requests through an [ntfy](https://ntfy.sh) +server instead of Telegram. A broker has one channel: `[telegram]` or +`[ntfy]`, never both. Skip the steps above if you use ntfy. + +ntfy is weaker than Telegram: it does not say who pressed a button, and +the server can change a message without the broker seeing it. Read +[THREAT-MODEL.md T40](../THREAT-MODEL.md#6-threats-and-answers) first. +Two limits follow from it: + +- **Run the ntfy server yourself.** This guide does not use the public + server ntfy.sh: whoever runs the server can approve. +- **No standing grants.** A config with `[ntfy]` and `[standing]` does + not load. Scheduled grants (`nodkey preapprove`) work. + +The examples use the server `https://ntfy.example.com`, the request +topic `nodkey-requests` and the answer topic `nodkey-answers`. The broker +sends each request to the request topic, and your phone reads it there. +A button on the phone sends its answer to the answer topic, which only +the broker reads. + +1. **The server.** Set up ntfy on a machine that you trust + ([install](https://docs.ntfy.sh/install/)), with `https`. In its + `server.yml`: + + ```yaml + base-url: "https://ntfy.example.com" + auth-file: "/var/lib/ntfy/user.db" + auth-default-access: "deny-all" + upstream-base-url: "https://ntfy.sh" + ``` + + `deny-all` means that nobody may read or write a topic without an + access rule. `upstream-base-url` is for iOS: without it, a message can + take hours to reach an iPhone. Only the message ID and a hash of the + topic address go to ntfy.sh; the phone then fetches the message from + your server. Leave the line out if you use only Android. +2. **The users and the access rules.** On the ntfy server + ([access control](https://docs.ntfy.sh/config/#access-control)): + + ```sh + ntfy user add nodkey-broker # asks for a password + ntfy user add phone + ntfy access nodkey-broker nodkey-requests write-only + ntfy access nodkey-broker nodkey-answers read-only + ntfy access phone nodkey-requests read-only + ntfy access everyone nodkey-answers write-only + ``` + + The buttons send no token, so anyone may write to the answer topic. + An answer counts only with the random value of its button, and only + the broker can read the answers. Give no other user access to these + two topics. +3. **The token of the broker.** On the ntfy server: + + ```sh + ntfy token add --label="nodkey broker" nodkey-broker + ``` + + On the broker host, save the token as a credential: + + ```sh + systemd-ask-password -n "ntfy token:" | + systemd-creds encrypt --with-key="$WITH_KEY" --name=ntfy-token - /etc/nodkey/credentials/ntfy-token.cred + ``` + + Never paste the token into a chat, an issue, a file in a repository, + or a command line. The example unit already has the line + `LoadCredentialEncrypted=ntfy-token:`; remove the line of + `telegram-token` ([section 8](#8-the-systemd-unit)). +4. **The config.** In place of `[telegram]` + ([section 7](#7-the-config-file)): + + ```toml + [ntfy] + server = "https://ntfy.example.com" + request_topic = "nodkey-requests" # the phone subscribes here + answer_topic = "nodkey-answers" # the buttons answer here; only the broker reads it + token_file = "${CREDENTIALS_DIRECTORY}/ntfy-token" + timeout = "120s" + ssh_timeout = "90s" + ``` + + `timeout` and `ssh_timeout` are as in `[telegram]`. There is no + `notice_chat_id`: notices go to the request topic too, with a low + priority. +5. **The phone.** Install the ntfy app (Android or iOS). In its + settings, add the user `phone` with its password for + `https://ntfy.example.com`. Then subscribe to `nodkey-requests` on + that server. On Android, turn on instant delivery for the + subscription. Never subscribe to `nodkey-answers`. +6. **Keep the request topic off agent machines.** Anyone who can read + the request topic can approve. **Never subscribe to it on a machine + where an agent runs**: not in the ntfy web app, not in a desktop + client, not with `curl`. Never log in as `phone` or + `nodkey-broker` there. + +**A new token.** Make a new one with `ntfy token add`, make the +credential again as in step 3, and restart the broker. Then remove the +old one with `ntfy token remove nodkey-broker `. When the +server refuses the token, the broker stops with exit code 77, and +systemd does not start it again until you do. + ## 5. Stores For each value, the broker runs the program of the store: `bws` or `bw` @@ -922,7 +1026,9 @@ from [section 3](#the-tls-key-of-the-broker). The home directories of the stores are in `stores`, next to `audit_log`. **`[telegram]`.** `token_file` and `user_id` come from -[section 4](#4-the-telegram-bot). `notice_chat_id` is optional. +[section 4](#4-the-telegram-bot). `notice_chat_id` is optional. For +ntfy, use `[ntfy]` in its place +([section 4](#ntfy-in-place-of-the-telegram-bot)). `timeout` is how long a request for secrets waits for your tap: 15 seconds to 10 minutes, 120 seconds by default. `ssh_timeout` is the same for an SSH login: 15 to 110 seconds, 90 seconds by default. sshd drops a @@ -1129,6 +1235,15 @@ Anyone who can use your Telegram account can approve - Turn on two-step verification (**Settings → Privacy and Security**), and a passcode for the app. +### The ntfy request topic + +With ntfy, anyone who can read the request topic can approve +([THREAT-MODEL.md T40](../THREAT-MODEL.md#6-threats-and-answers)). Run +the server yourself, with `auth-default-access: deny-all`. Never +subscribe to the request topic on a machine where an agent runs, and +never keep an ntfy login there +([section 4](#ntfy-in-place-of-the-telegram-bot)). + ### The Bitwarden accounts that can read the secrets Anyone who can use a Bitwarden account that can read the secrets can diff --git a/internal/approval/approval.go b/internal/approval/approval.go index 810de2b..8de1ce3 100644 --- a/internal/approval/approval.go +++ b/internal/approval/approval.go @@ -136,17 +136,20 @@ type Core struct { // New returns a core that decides requests by the rules of cfg and asks // the person through a. cfg must come from config.LoadBroker, and nothing -// may change it later. The approver's user ID, cfg.Telegram.UserID, is the -// only one whose answers count. +// may change it later. With [telegram], the approver's user ID, +// cfg.Telegram.UserID, is the only one whose answers count. With [ntfy], +// nobody's identity is known: the ntfy approver checks the random value of +// the request, and the core takes its answers with user ID 0. func New(cfg *config.Broker, a Approver, opts Options) (*Core, error) { switch { case cfg == nil: return nil, errors.New("approval: the broker config is missing") case a == nil: return nil, errors.New("approval: the approver is missing") - case cfg.Telegram.UserID <= 0: + case cfg.Ntfy == nil && cfg.Telegram.UserID <= 0: return nil, errors.New("approval: the broker config has no approver user ID") - case cfg.Telegram.Timeout <= 0 || cfg.Telegram.SSHTimeout <= 0: + } + if t, ssh := cfg.Timeouts(); t <= 0 || ssh <= 0 { return nil, errors.New("approval: the broker config has no request timeouts") } stopCtx, stopRuns := context.WithCancel(context.Background()) @@ -255,7 +258,28 @@ func (c *Core) callContext(ctx context.Context, d time.Duration) (context.Contex // of secrets. func (c *Core) timeout(k audit.Kind) time.Duration { if k == audit.KindSSHLogin { - return c.cfg.Telegram.SSHTimeout + _, ssh := c.cfg.Timeouts() + return ssh + } + t, _ := c.cfg.Timeouts() + return t +} + +// fromApprover reports whether a comes from the approver. With [telegram], +// that is the configured user ID. With [ntfy], the ntfy approver has +// checked the value of the request already, and a has no user ID. +func (c *Core) fromApprover(a Answer) bool { + if c.cfg.Ntfy != nil { + return a.UserID == 0 + } + return a.UserID > 0 && a.UserID == c.cfg.Telegram.UserID +} + +// channel returns the entry text that says which channel an answer came +// by, or "" for Telegram, where the user ID says it. +func (c *Core) channel() string { + if c.cfg.Ntfy != nil { + return audit.ChannelNtfyPrefix + c.cfg.Ntfy.AnswerTopic } - return c.cfg.Telegram.Timeout + return "" } diff --git a/internal/approval/decide.go b/internal/approval/decide.go index 7d8154d..4ee4f98 100644 --- a/internal/approval/decide.go +++ b/internal/approval/decide.go @@ -512,7 +512,7 @@ func (c *Core) waitLook(ctx context.Context, p *pending) *Result { // for each button press. The answer counts only if all of these are true // (DESIGN.md section 7): // -// - it is from the approver: a.UserID is cfg.Telegram.UserID; +// - it is from the approver: a.UserID is cfg.Telegram.UserID (with [ntfy]: 0); // - the request a.RequestID waits for an answer; // - it is on the request's own message; // - the request has not expired, on either clock; @@ -539,7 +539,7 @@ func (c *Core) Answer(a Answer) error { if c.stopped { return ErrStopped } - if a.UserID <= 0 || a.UserID != c.cfg.Telegram.UserID { + if !c.fromApprover(a) { return ErrNotApprover } if a.Action == audit.AnswerRevoke { @@ -562,6 +562,7 @@ func (c *Core) Answer(a Answer) error { res := c.newResult(p) res.Answer = a.Action res.UserID = a.UserID + res.Channel = c.channel() switch a.Action { case audit.AnswerApproveOnce: if !p.once { diff --git a/internal/approval/ntfy_test.go b/internal/approval/ntfy_test.go new file mode 100644 index 0000000..2876fb0 --- /dev/null +++ b/internal/approval/ntfy_test.go @@ -0,0 +1,70 @@ +package approval_test + +import ( + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/approval/approvaltest" + "github.com/code6x/nodkey/internal/audit" + "github.com/code6x/nodkey/internal/config" +) + +// useNtfy makes cfg a config with the ntfy channel and no Telegram. +func useNtfy(cfg *config.Broker) { + cfg.Telegram = config.Telegram{} + cfg.Ntfy = &config.Ntfy{ + Server: "https://ntfy.example.com", RequestTopic: "req-topic", AnswerTopic: "ans-topic", + TokenFile: "/nonexistent/token", Timeout: 2 * time.Minute, SSHTimeout: 90 * time.Second, + } +} + +// TestNtfyAnswerWithoutUserID: with [ntfy], an answer without a user ID +// counts, and the entry names the channel and the answer topic. An answer +// with a user ID is refused. +func TestNtfyAnswerWithoutUserID(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + e := newEnv(t, useNtfy) + defer e.stop() + ch := e.decide(secretsReq("laptop", "db-password")) + mustWait(t, ch) + e.asked(1) + if err := e.answer(1, func(x *approval.Answer) { x.UserID = approverID }); !errors.Is(err, approval.ErrNotApprover) { + t.Errorf("an answer with a user ID: %v, want ErrNotApprover", err) + } + mustWait(t, ch) + if err := e.answer(1, func(x *approval.Answer) { x.UserID = 0 }); err != nil { + t.Fatalf("an answer without a user ID: %v", err) + } + r := mustResult(t, ch) + checkAllowed(t, r) + var found bool + for _, ev := range r.Events() { + if d, ok := ev.(audit.Decision); ok { + found = true + if d.UserID != 0 || d.Channel != "ntfy:ans-topic" { + t.Errorf("decision entry: user %d, channel %q", d.UserID, d.Channel) + } + } + } + if !found { + t.Error("no decision entry") + } + }) +} + +// TestNtfyConfigNeedsTimeouts: with [ntfy], New needs the timeouts of the +// ntfy section, and no Telegram user ID. +func TestNtfyConfigNeedsTimeouts(t *testing.T) { + cfg, _ := testConfig(t) + useNtfy(cfg) + if _, err := approval.New(cfg, approvaltest.NewApprover(approverID), approval.Options{Clock: approvaltest.NewClock(noon)}); err != nil { + t.Fatalf("New with ntfy: %v", err) + } + cfg.Ntfy.Timeout = 0 + if _, err := approval.New(cfg, approvaltest.NewApprover(approverID), approval.Options{Clock: approvaltest.NewClock(noon)}); err == nil { + t.Error("New accepted ntfy without a timeout") + } +} diff --git a/internal/approval/renew.go b/internal/approval/renew.go index c63fa9c..b2b31dd 100644 --- a/internal/approval/renew.go +++ b/internal/approval/renew.go @@ -136,7 +136,7 @@ func (c *Core) renew(a Answer) error { if c.stopped { return ErrStopped } - if a.UserID <= 0 || a.UserID != c.cfg.Telegram.UserID { + if !c.fromApprover(a) { return ErrNotApprover } now := c.now() @@ -198,7 +198,7 @@ func (c *Core) renew(a Answer) error { n.prompt = Prompt{RequestID: reqID, ShortCode: g.code, Kind: audit.KindStanding, Client: g.client, Secrets: slices.Clone(n.origin.Secrets), Uses: g.perWindow, From: now.wall.In(loc), Until: end.In(loc), Windows: slices.Clone(g.origin.Windows)} - ev := audit.Decision{Subject: n.origin, UserID: a.UserID, Answer: audit.AnswerRenew, RenewOf: g.id} + ev := audit.Decision{Subject: n.origin, UserID: a.UserID, Channel: c.channel(), Answer: audit.AnswerRenew, RenewOf: g.id} ev.Secrets = slices.Clone(n.origin.Secrets) ev.Windows = slices.Clone(n.origin.Windows) f := c.onRenewed diff --git a/internal/approval/request.go b/internal/approval/request.go index 96a3ff9..62c271b 100644 --- a/internal/approval/request.go +++ b/internal/approval/request.go @@ -87,6 +87,8 @@ type Result struct { Answer audit.Answer // UserID is the Telegram user ID that answered. UserID int64 + // Channel is "ntfy:TOPIC" for an answer through ntfy, else empty. + Channel string // WindowMinutes is the length of the grants of an approve_window // answer. WindowMinutes int @@ -167,7 +169,7 @@ func (r *Result) mainEvents() []audit.Event { switch { case r.Answer != "": events = append(events, audit.Decision{ - Subject: r.Subject, UserID: r.UserID, Answer: r.Answer, WindowMinutes: r.WindowMinutes, + Subject: r.Subject, UserID: r.UserID, Channel: r.Channel, Answer: r.Answer, WindowMinutes: r.WindowMinutes, }) if !r.Allowed { events = append(events, audit.Denied{Subject: r.Subject, Cause: r.Cause}) diff --git a/internal/audit/event.go b/internal/audit/event.go index 018a2da..7c9bee9 100644 --- a/internal/audit/event.go +++ b/internal/audit/event.go @@ -219,7 +219,11 @@ type Request struct { type Decision struct { Subject // UserID is the Telegram user ID that answered. + // With ntfy it is 0, and Channel says where the answer came from. UserID int64 `json:"user_id"` + // Channel is "ntfy:" and the answer topic, for an answer through ntfy. + // It is empty for Telegram. + Channel string `json:"channel,omitempty"` // Answer is the button that was pressed. Answer Answer `json:"answer"` // WindowMinutes is the length of the grant, for AnswerApproveWindow @@ -488,11 +492,22 @@ func (e Request) validate() error { return e.Subject.validate(true) } func (e SecretSent) validate() error { return e.Subject.validate(true) } func (e Signed) validate() error { return e.Subject.validate(true) } +// ChannelNtfyPrefix starts the Channel of a Decision that came through +// ntfy. +const ChannelNtfyPrefix = "ntfy:" + +var channelPattern = regexp.MustCompile(`^ntfy:[A-Za-z0-9_-]{1,64}$`) + func (e Decision) validate() error { if err := e.Subject.validate(true); err != nil { return err } - if e.UserID <= 0 { + switch { + case e.Channel != "": + if e.UserID != 0 || !channelPattern.MatchString(e.Channel) { + return errors.New("channel is \"ntfy:\" and a topic name, and then user_id is 0") + } + case e.UserID <= 0: return errors.New("user_id must be a Telegram user ID") } if (e.Answer == AnswerRenew) != (e.RenewOf != "") { diff --git a/internal/audit/event_test.go b/internal/audit/event_test.go index d63c746..301306a 100644 --- a/internal/audit/event_test.go +++ b/internal/audit/event_test.go @@ -102,6 +102,9 @@ func TestEncodeEventRefusesBadFields(t *testing.T) { {"version that is not a version", BrokerStarted{Version: "1.0 " + canary}}, {"no version", BrokerStarted{}}, {"decision without a user", Decision{Subject: loginSubject(), Answer: AnswerApproveOnce}}, + {"channel with a user", Decision{Subject: loginSubject(), UserID: 1, Channel: "ntfy:ans", Answer: AnswerApproveOnce}}, + {"bad channel", Decision{Subject: loginSubject(), Channel: "ntfy:a/b", Answer: AnswerApproveOnce}}, + {"channel of another kind", Decision{Subject: loginSubject(), Channel: "sms:ans", Answer: AnswerApproveOnce}}, {"unknown answer", Decision{Subject: loginSubject(), UserID: 1, Answer: canary}}, {"window without minutes", Decision{Subject: loginSubject(), UserID: 1, Answer: AnswerApproveWindow}}, {"window too long", Decision{Subject: loginSubject(), UserID: 1, Answer: AnswerApproveWindow, WindowMinutes: 481}}, @@ -207,6 +210,7 @@ func TestStandingEvents(t *testing.T) { ok.Kind = KindStanding ok.Windows = []string{"daily 03:00-05:00", "mon,thu 10:30-11:00"} good("a standing request", Request{Subject: ok}) + good("a decision through ntfy", Decision{Subject: ok, Channel: "ntfy:ans-1", Answer: AnswerApproveOnce}) good("a standing decision", Decision{Subject: ok, UserID: 1, Answer: AnswerApproveOnce}) h := strings.Repeat("ab", 32) good("a standing use", GrantUsed{Subject: ok, Via: ViaStanding, NewTokenHash: h}) diff --git a/internal/config/broker.go b/internal/config/broker.go index ccc5779..8bef11e 100644 --- a/internal/config/broker.go +++ b/internal/config/broker.go @@ -7,6 +7,7 @@ import ( "maps" "net" "net/netip" + "net/url" "os" "path/filepath" "regexp" @@ -49,14 +50,18 @@ type Broker struct { AuditLog string KnownHostsFile string KnownHosts *KnownHosts - Telegram Telegram - Updates Updates - Preapprove PreapproveLimits - Standing StandingLimits - Clients map[string]Client // by name - Stores map[string]Store // by name - Secrets map[string]Secret // by name - Keys map[string]Key // by name + // Telegram is the [telegram] section. It is the zero value when the + // config has [ntfy] instead: exactly one of them is set (see Ntfy). + Telegram Telegram + // Ntfy is the [ntfy] section, or nil when the channel is Telegram. + Ntfy *Ntfy + Updates Updates + Preapprove PreapproveLimits + Standing StandingLimits + Clients map[string]Client // by name + Stores map[string]Store // by name + Secrets map[string]Secret // by name + Keys map[string]Key // by name } // Telegram is the [telegram] section. @@ -68,6 +73,35 @@ type Telegram struct { SSHTimeout time.Duration // how long an SSH request waits; default 90s } +// Ntfy is the [ntfy] section: the approval channel through an ntfy server +// (DESIGN.md section 7). A broker has one channel, [telegram] or [ntfy]. +type Ntfy struct { + Server string // the base URL: https, no user info, no query or fragment + RequestTopic string // the broker publishes requests and notices here + AnswerTopic string // the buttons answer here; only the broker reads it + TokenFile string // the file that holds the access token + Timeout time.Duration // how long a request for secrets waits; default 120s + SSHTimeout time.Duration // how long an SSH request waits; default 90s +} + +// Timeouts returns how long a request for secrets waits and how long an +// SSH request waits, from the channel that the config has. +func (b *Broker) Timeouts() (timeout, sshTimeout time.Duration) { + if b.Ntfy != nil { + return b.Ntfy.Timeout, b.Ntfy.SSHTimeout + } + return b.Telegram.Timeout, b.Telegram.SSHTimeout +} + +// ApproverTokenFile returns the token file of the channel that the config +// has. +func (b *Broker) ApproverTokenFile() string { + if b.Ntfy != nil { + return b.Ntfy.TokenFile + } + return b.Telegram.TokenFile +} + // Updates is the [updates] section. Without it, each key has its // default. type Updates struct { @@ -222,7 +256,8 @@ type ( TLSKey string `toml:"tls_key"` AuditLog string `toml:"audit_log"` KnownHosts string `toml:"known_hosts"` - Telegram telegramSection `toml:"telegram"` + Telegram *telegramSection `toml:"telegram"` + Ntfy *ntfySection `toml:"ntfy"` Updates updatesSection `toml:"updates"` Preapprove preapproveSection `toml:"preapprove"` Standing *standingSection `toml:"standing"` @@ -238,6 +273,14 @@ type ( Timeout *string `toml:"timeout"` SSHTimeout *string `toml:"ssh_timeout"` } + ntfySection struct { + Server string `toml:"server"` + RequestTopic string `toml:"request_topic"` + AnswerTopic string `toml:"answer_topic"` + TokenFile string `toml:"token_file"` + Timeout *string `toml:"timeout"` + SSHTimeout *string `toml:"ssh_timeout"` + } updatesSection struct { Check *bool `toml:"check"` } @@ -365,8 +408,24 @@ func newBroker(f *brokerFile) (*Broker, error) { if b.KnownHostsFile, err = parsePath("", "known_hosts", f.KnownHosts); err != nil { return nil, err } - if b.Telegram, err = parseTelegram(&f.Telegram); err != nil { - return nil, err + switch { + case f.Telegram != nil && f.Ntfy != nil: + return nil, errors.New("[telegram] and [ntfy]: a broker has one approval channel; remove one of them") + case f.Ntfy != nil: + n, err := parseNtfy(f.Ntfy) + if err != nil { + return nil, err + } + b.Ntfy = &n + default: + // Without any section, the error is the one of a missing [telegram] key. + ts := f.Telegram + if ts == nil { + ts = &telegramSection{} + } + if b.Telegram, err = parseTelegram(ts); err != nil { + return nil, err + } } b.Updates.Check = f.Updates.Check == nil || *f.Updates.Check if b.Preapprove, err = parsePreapprove(&f.Preapprove); err != nil { @@ -375,6 +434,11 @@ func newBroker(f *brokerFile) (*Broker, error) { if b.Standing, err = parseStanding(f.Standing); err != nil { return nil, err } + if b.Standing.Enabled && b.Ntfy != nil { + // The ntfy channel cannot send the alarm, the warning or the summary + // of a standing grant (DESIGN.md section 8), so it allows none. + return nil, errors.New("[standing] and [ntfy]: standing grants need the [telegram] channel; remove [standing]") + } if err := b.addClients(f.Client); err != nil { return nil, err } @@ -478,15 +542,67 @@ func parseTelegram(s *telegramSection) (Telegram, error) { } t.NoticeChatID = *s.NoticeChatID } - if t.Timeout, err = parseTimeout(sec, "timeout", s.Timeout, defaultTimeout, MaxTimeout); err != nil { - return Telegram{}, err - } - if t.SSHTimeout, err = parseTimeout(sec, "ssh_timeout", s.SSHTimeout, defaultSSHTimeout, MaxSSHTimeout); err != nil { + if t.Timeout, t.SSHTimeout, err = parseTimeouts(sec, s.Timeout, s.SSHTimeout); err != nil { return Telegram{}, err } return t, nil } +// parseTimeouts parses the timeout and ssh_timeout keys that [telegram] +// and [ntfy] share, with their defaults and limits. +func parseTimeouts(sec string, timeout, ssh *string) (time.Duration, time.Duration, error) { + t, err := parseTimeout(sec, "timeout", timeout, defaultTimeout, MaxTimeout) + if err != nil { + return 0, 0, err + } + st, err := parseTimeout(sec, "ssh_timeout", ssh, defaultSSHTimeout, MaxSSHTimeout) + if err != nil { + return 0, 0, err + } + return t, st, nil +} + +// topicPattern is the rule for the names of ntfy topics. +var topicPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +func parseNtfy(s *ntfySection) (Ntfy, error) { + const sec = "[ntfy]" + var n Ntfy + var err error + if s.Server == "" { + return Ntfy{}, fmt.Errorf("%s: is missing", place(sec, "server")) + } + u, err := url.Parse(s.Server) + switch { + case err != nil: + return Ntfy{}, fmt.Errorf("%s: must be a URL such as \"https://ntfy.example.com\"", place(sec, "server")) + case u.Scheme != "https": + return Ntfy{}, fmt.Errorf("%s: must start with https://", place(sec, "server")) + case u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" || u.ForceQuery || strings.Contains(s.Server, "#"): + return Ntfy{}, fmt.Errorf("%s: must be only https://, a host, and an optional path: no user name, query or fragment", place(sec, "server")) + } + n.Server = s.Server + for _, t := range []struct{ key, v string }{{"request_topic", s.RequestTopic}, {"answer_topic", s.AnswerTopic}} { + if t.v == "" { + return Ntfy{}, fmt.Errorf("%s: is missing", place(sec, t.key)) + } + if !topicPattern.MatchString(t.v) { + return Ntfy{}, fmt.Errorf("%s: must be 1 to 64 letters, digits, \"_\" or \"-\"", place(sec, t.key)) + } + } + if s.RequestTopic == s.AnswerTopic { + return Ntfy{}, fmt.Errorf("%s: must not be the same as request_topic", place(sec, "answer_topic")) + } + n.RequestTopic, n.AnswerTopic = s.RequestTopic, s.AnswerTopic + if n.TokenFile, err = parsePath(sec, "token_file", s.TokenFile); err != nil { + return Ntfy{}, err + } + if n.Timeout, n.SSHTimeout, err = parseTimeouts(sec, s.Timeout, s.SSHTimeout); err != nil { + return Ntfy{}, err + } + return n, nil +} + // parsePreapprove checks the [preapprove] section and fills in the // defaults. func parsePreapprove(s *preapproveSection) (PreapproveLimits, error) { @@ -907,6 +1023,13 @@ func (b *Broker) CheckFiles() error { return b.CheckFilesAs(self, nil) } +func (b *Broker) approverTokenPlace() string { + if b.Ntfy != nil { + return place("[ntfy]", "token_file") + } + return place("[telegram]", "token_file") +} + // CheckFilesAs is CheckFiles for the user ID self, the user that runs the // broker, such as when root runs nodkey doctor broker. It skips each file // that holds a key, a token or a password, and the config file and @@ -922,7 +1045,7 @@ func (b *Broker) CheckFilesAs(self uint32, skip func(path string) bool) error { } private := []file{ {"tls_key", b.TLSKey, false}, - {place("[telegram]", "token_file"), b.Telegram.TokenFile, false}, + {b.approverTokenPlace(), b.ApproverTokenFile(), false}, } for _, name := range slices.Sorted(maps.Keys(b.Stores)) { s := b.Stores[name] diff --git a/internal/config/ntfy_test.go b/internal/config/ntfy_test.go new file mode 100644 index 0000000..d046e92 --- /dev/null +++ b/internal/config/ntfy_test.go @@ -0,0 +1,152 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const telegramSectionText = "[telegram]\ntoken_file = \"${CREDENTIALS_DIRECTORY}/telegram-token\"\nuser_id = 123456789\n" + +func ntfySectionText(extra string) string { + return "[ntfy]\nserver = \"https://ntfy.example.com\"\nrequest_topic = \"nodkey-req\"\n" + + "answer_topic = \"nodkey-ans\"\ntoken_file = \"${CREDENTIALS_DIRECTORY}/telegram-token\"\n" + extra +} + +func TestLoadBrokerNtfy(t *testing.T) { + e := newBrokerEnv(t) + b, err := LoadBroker(e.write(t, edit{telegramSectionText, ntfySectionText("timeout = \"3m\"\n")})) + if err != nil { + t.Fatal(err) + } + n := b.Ntfy + if n == nil { + t.Fatal("Ntfy is nil") + } + if n.Server != "https://ntfy.example.com" || n.RequestTopic != "nodkey-req" || n.AnswerTopic != "nodkey-ans" { + t.Errorf("Ntfy = %+v", n) + } + if n.TokenFile != filepath.Join(e.creds, "telegram-token") { + t.Errorf("TokenFile = %q", n.TokenFile) + } + if got, ssh := b.Timeouts(); got != 3*time.Minute || ssh != 90*time.Second { + t.Errorf("Timeouts = %v, %v", got, ssh) + } + if b.Telegram != (Telegram{}) { + t.Errorf("Telegram = %+v, want the zero value", b.Telegram) + } + if b.ApproverTokenFile() != n.TokenFile { + t.Error("ApproverTokenFile is not the ntfy token file") + } + if err := b.CheckFiles(); err != nil { + t.Errorf("CheckFiles: %v", err) + } + // A path in the server URL is fine. + if _, err := LoadBroker(e.write(t, edit{telegramSectionText, + strings.Replace(ntfySectionText(""), "ntfy.example.com", "example.com/ntfy", 1)})); err != nil { + t.Errorf("server with a path: %v", err) + } +} + +func TestLoadBrokerTelegramTimeouts(t *testing.T) { + b, err := LoadBroker(newBrokerEnv(t).write(t)) + if err != nil { + t.Fatal(err) + } + if b.Ntfy != nil { + t.Error("Ntfy is set for a telegram config") + } + if got, ssh := b.Timeouts(); got != 120*time.Second || ssh != 90*time.Second { + t.Errorf("Timeouts = %v, %v", got, ssh) + } +} + +func TestLoadBrokerNtfyErrors(t *testing.T) { + tests := []struct { + name string + edits func() []edit + want string + }{ + {"both sections", func() []edit { + return []edit{{telegramSectionText, telegramSectionText + "\n" + ntfySectionText("")}} + }, "one approval channel"}, + {"none", func() []edit { return []edit{{telegramSectionText, ""}} }, "[telegram] token_file: is missing"}, + {"http server", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "https://", "http://", 1)}} + }, "[ntfy] server: must start with https://"}, + {"user info", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "https://", "https://u:p@", 1)}} + }, "no user name, query or fragment"}, + {"query", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "com\"", "com?x=1\"", 1)}} + }, "no user name, query or fragment"}, + {"empty query", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "com\"", "com?\"", 1)}} + }, "no user name, query or fragment"}, + {"fragment", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "com\"", "com#x\"", 1)}} + }, "no user name, query or fragment"}, + {"no server", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "server = \"https://ntfy.example.com\"\n", "", 1)}} + }, "[ntfy] server: is missing"}, + {"bad topic", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "nodkey-req", "bad/topic", 1)}} + }, "[ntfy] request_topic: must be 1 to 64"}, + {"long topic", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "nodkey-ans", strings.Repeat("a", 65), 1)}} + }, "[ntfy] answer_topic: must be 1 to 64"}, + {"no topic", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "request_topic = \"nodkey-req\"\n", "", 1)}} + }, "[ntfy] request_topic: is missing"}, + {"same topics", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "nodkey-ans", "nodkey-req", 1)}} + }, "[ntfy] answer_topic: must not be the same"}, + {"no token file", func() []edit { + return []edit{{telegramSectionText, strings.Replace(ntfySectionText(""), "token_file = \"${CREDENTIALS_DIRECTORY}/telegram-token\"\n", "", 1)}} + }, "[ntfy] token_file: is missing"}, + {"timeout too short", func() []edit { + return []edit{{telegramSectionText, ntfySectionText("timeout = \"1s\"\n")}} + }, "[ntfy] timeout: must be from"}, + {"ssh timeout too long", func() []edit { + return []edit{{telegramSectionText, ntfySectionText("ssh_timeout = \"5m\"\n")}} + }, "[ntfy] ssh_timeout: must be from"}, + {"standing grants", func() []edit { + return []edit{{telegramSectionText, ntfySectionText("") + "\n[standing]\n"}} + }, "[standing] and [ntfy]"}, + {"unknown key", func() []edit { + return []edit{{telegramSectionText, ntfySectionText("user_id = 1\n")}} + }, "user_id"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := newBrokerEnv(t) + _, err := LoadBroker(e.write(t, tt.edits()...)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error %v, want it to hold %q", err, tt.want) + } + }) + } +} + +func TestCheckFilesNtfyToken(t *testing.T) { + e := newBrokerEnv(t) + b, err := LoadBroker(e.write(t, edit{telegramSectionText, ntfySectionText("")})) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(e.creds, "telegram-token") + chmod(t, p, 0o640) + err = b.CheckFiles() + if err == nil || !strings.Contains(err.Error(), "[ntfy] token_file: "+p) { + t.Errorf("open token file: error %v", err) + } + chmod(t, p, 0o600) + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + if err := b.CheckFiles(); err == nil || !strings.Contains(err.Error(), "[ntfy] token_file: ") { + t.Errorf("missing token file: error %v", err) + } +} diff --git a/internal/doctor/broker.go b/internal/doctor/broker.go index af12484..3d385fc 100644 --- a/internal/doctor/broker.go +++ b/internal/doctor/broker.go @@ -185,7 +185,7 @@ func credFileExists(env BrokerEnv, name, file string) bool { // credNames returns the names of the credentials that the config uses: // the first part of each path in credDir, sorted, each once. func credNames(cfg *config.Broker, credDir string) []string { - paths := []string{cfg.TLSKey, cfg.AuditLog, cfg.KnownHostsFile, cfg.Telegram.TokenFile} + paths := []string{cfg.TLSKey, cfg.AuditLog, cfg.KnownHostsFile, cfg.ApproverTokenFile()} for _, s := range cfg.Stores { paths = append(paths, s.TokenFile, s.PasswordFile, s.Program) } diff --git a/internal/ntfy/api.go b/internal/ntfy/api.go new file mode 100644 index 0000000..a90e0eb --- /dev/null +++ b/internal/ntfy/api.go @@ -0,0 +1,331 @@ +package ntfy + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// This file is the small client for the ntfy server +// (https://docs.ntfy.sh/publish/ and https://docs.ntfy.sh/subscribe/api/). +// +// No error of this file holds Go's error text or the server's text: an +// error names the call and a fixed reason, and at most the HTTP status. +// The tokens are in a header, never in a URL, and never in an error. + +const ( + // callTimeout bounds one try of a publish. + callTimeout = 15 * time.Second + // maxTries is the most tries of one publish. + maxTries = 3 + // firstBackoff and maxBackoff bound the wait before the next try. + firstBackoff = 500 * time.Millisecond + maxBackoff = 4 * time.Second + // maxResponseSize is the largest answer to a publish that the client + // reads. The answer is one message, at most a few kilobytes. + maxResponseSize = 64 << 10 +) + +// topicPattern is the form of a topic name, as ntfy allows it: 1 to 64 +// letters, digits, "_" or "-". +var topicPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// idPattern is the form of an ntfy message ID: the server makes 12 +// letters and digits. A few more are allowed. +var idPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`) + +// tokenPattern is the form of an access token: printable ASCII without +// space, so it can go into a header as it is. ntfy's own tokens are +// "tk_" and 29 letters and digits. +var tokenPattern = regexp.MustCompile(`^[\x21-\x7e]{8,256}$`) + +// redacted is what a Token shows instead of its value. +const redacted = "[ntfy token]" + +// Token is an access token of the ntfy server. It hides its value in +// String, Format and LogValue, so a log line or an error that prints it by +// mistake shows "[ntfy token]". Make it with NewToken. +type Token struct{ value string } + +// NewToken returns the token s, after space around it is removed. The +// error never shows s. +func NewToken(s string) (Token, error) { + s = strings.TrimSpace(s) + if !tokenPattern.MatchString(s) { + return Token{}, errors.New("ntfy: the access token is not valid: 8 to 256 printable characters without space") + } + return Token{value: s}, nil +} + +func (Token) String() string { return redacted } +func (Token) GoString() string { return redacted } +func (Token) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, redacted) } +func (Token) LogValue() slog.Value { return slog.StringValue(redacted) } +func (Token) MarshalText() ([]byte, error) { return []byte(redacted), nil } + +// set reports whether the token has a value. +func (t Token) set() bool { return t.value != "" } + +// Config says which ntfy server and topics the approver uses. +type Config struct { + // Server is the base URL of the ntfy server, such as + // "https://ntfy.example.com". It must use https. + Server string + // RequestTopic gets the messages: the requests with their buttons and + // the notices. The person's phone subscribes to it. + RequestTopic string + // AnswerTopic gets the answers that the buttons send. Only the broker + // may read it. + AnswerTopic string + // PublishToken may publish to RequestTopic. ReadToken may read + // AnswerTopic. They may be the same token. + PublishToken, ReadToken Token +} + +// check checks cfg and returns the server URL without a "/" at the end. +func (cfg Config) check() (string, error) { + u, err := url.Parse(cfg.Server) + switch { + case err != nil: + return "", errors.New("ntfy: the server URL is not valid") + case u.Scheme != "https": + return "", errors.New("ntfy: the server URL must start with https://") + case u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || + u.Fragment != "" || strings.Contains(cfg.Server, "#") || u.Opaque != "": + return "", errors.New("ntfy: the server URL must be only https://, a host, and an optional path") + case !topicPattern.MatchString(cfg.RequestTopic) || !topicPattern.MatchString(cfg.AnswerTopic): + return "", errors.New("ntfy: a topic name must be 1 to 64 letters, digits, \"_\" or \"-\"") + case cfg.RequestTopic == cfg.AnswerTopic: + return "", errors.New("ntfy: the request topic and the answer topic must not be the same") + case !cfg.PublishToken.set() || !cfg.ReadToken.set(): + return "", errors.New("ntfy: the approver needs a token to publish and a token to read") + } + return strings.TrimRight(u.String(), "/"), nil +} + +// callError is an error of one call to the server. Its text names the +// call and a fixed reason, never Go's error text or the server's text. +type callError struct { + call string + reason string // a fixed text + status int // the HTTP status; 0 if no answer came + temporary bool // another try may work + err error // a context error, or nil +} + +func (e *callError) Error() string { + s := "ntfy: " + e.call + ": " + e.reason + if e.status != 0 { + s += " (" + strconv.Itoa(e.status) + ")" + } + return s +} + +func (e *callError) Unwrap() error { return e.err } + +// client calls the ntfy server. It is safe for use by many goroutines. +type client struct { + http *http.Client + base string + publishToken, readToken Token + timeout time.Duration + sleep func(ctx context.Context, d time.Duration) error +} + +// Format hides the client, so that no fmt verb prints a token. +func (*client) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, "ntfy.client") } + +// newHTTPClient returns the HTTP client for the ntfy server: TLS 1.2 or +// newer, a limit for each step of a connection, and no redirect, so no +// other server gets a token. +func newHTTPClient() *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + MaxResponseHeaderBytes: 64 << 10, + IdleConnTimeout: 90 * time.Second, + MaxIdleConns: 4, + } + return &http.Client{Transport: tr, CheckRedirect: noRedirect} +} + +// noRedirect makes the client return a redirect as the answer. +func noRedirect(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + +// sleep waits for d, or until ctx ends. +func sleep(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// publishMessage is a message in the JSON form of a publish +// (https://docs.ntfy.sh/publish/#publish-as-json). +type publishMessage struct { + Topic string `json:"topic"` + Title string `json:"title,omitempty"` + Message string `json:"message"` + Priority int `json:"priority,omitempty"` + Actions []action `json:"actions,omitempty"` + SequenceID string `json:"sequence_id,omitempty"` +} + +// action is an "http" action button. The phone sends Body to URL with +// Method when the button is pressed, and then clears the notification. +type action struct { + Action string `json:"action"` + Label string `json:"label"` + URL string `json:"url"` + Method string `json:"method"` + Body string `json:"body"` + Clear bool `json:"clear"` +} + +// published is the part of the server's answer to a publish that the +// client reads. +type published struct { + ID string `json:"id"` + Event string `json:"event"` + Topic string `json:"topic"` +} + +// maxPublishBody is the most bytes that the approver sends as one publish +// body. ntfy refuses a JSON publish body over 8,192 bytes by default; the +// rest is a margin. +const maxPublishBody = 8192 - 1024 + +// encodeMessage returns the JSON body of the publish of m. It does not +// escape <, > and &: json.Marshal makes each of them 6 bytes, and the +// body would not fit. +func encodeMessage(m publishMessage) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(m); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +// fitsPublish reports whether the publish of m is within maxPublishBody. +func fitsPublish(m publishMessage) bool { + b, err := encodeMessage(m) + return err == nil && len(b) <= maxPublishBody +} + +// publish sends m, and returns the ID of the new message. It tries again +// after a server error, code 429 or a network error, at most maxTries +// times, and only while ctx leaves time for the wait; tries > 1 only when +// retry is set. A network error may come after the server took the +// message, so a try again can send it twice. That is safe: an answer +// counts only with the value of its request, and the core takes it only +// on the message that Ask returned. +func (c *client) publish(ctx context.Context, m publishMessage, retry bool) (string, error) { + body, err := encodeMessage(m) + if err != nil { + return "", &callError{call: "publish", reason: "cannot encode the message"} + } + backoff := firstBackoff + for try := 1; ; try++ { + id, e := c.publishOnce(ctx, body, m.Topic) + if e == nil { + return id, nil + } + if !retry || !e.temporary || try >= maxTries { + return "", e + } + wait := backoff + backoff = min(2*backoff, maxBackoff) + if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= wait { + return "", e + } + if c.sleep(ctx, wait) != nil { + return "", e + } + } +} + +// publishOnce makes one try of a publish of body to topic. +func (c *client) publishOnce(ctx context.Context, body []byte, topic string) (string, *callError) { + const call = "publish" + if err := ctx.Err(); err != nil { + return "", &callError{call: call, reason: "stopped before the call", err: err} + } + cctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodPost, c.base+"/", bytes.NewReader(body)) + if err != nil { + return "", &callError{call: call, reason: "cannot make the request"} + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.publishToken.value) + resp, err := c.http.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", &callError{call: call, reason: "stopped during the call", err: ctx.Err()} + } + return "", &callError{call: call, reason: netReason(err), temporary: true} + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1)) + switch { + case err != nil && ctx.Err() != nil: + return "", &callError{call: call, reason: "stopped during the call", err: ctx.Err()} + case err != nil: + return "", &callError{call: call, reason: "cannot read the answer", temporary: true} + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return "", &callError{call: call, reason: "the server refused the message for now", status: resp.StatusCode, temporary: true} + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return "", &callError{call: call, reason: "the server does not accept the token", status: resp.StatusCode} + case resp.StatusCode != http.StatusOK: + return "", &callError{call: call, reason: "the server refused the message", status: resp.StatusCode} + case len(data) > maxResponseSize: + return "", &callError{call: call, reason: "the answer is too large"} + } + var p published + if json.Unmarshal(data, &p) != nil || !idPattern.MatchString(p.ID) || p.Event != "message" || p.Topic != topic { + return "", &callError{call: call, reason: "the answer does not have the expected form"} + } + return p.ID, nil +} + +// netReason returns a fixed reason for an error of http.Client.Do. It +// looks only at the types in err, never at its text. +func netReason(err error) string { + var dnsErr *net.DNSError + var certErr *tls.CertificateVerificationError + var netErr net.Error + var opErr *net.OpError + switch { + case errors.As(err, &dnsErr): + return "cannot find the address of the server" + case errors.As(err, &certErr): + return "the TLS certificate of the server is not valid" + case errors.As(err, &netErr) && netErr.Timeout(): + return "the call timed out" + case errors.As(err, &opErr) && opErr.Op == "dial": + return "cannot connect to the server" + } + return "the connection failed" +} diff --git a/internal/ntfy/approver_test.go b/internal/ntfy/approver_test.go new file mode 100644 index 0000000..ff6d4c2 --- /dev/null +++ b/internal/ntfy/approver_test.go @@ -0,0 +1,718 @@ +package ntfy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/audit" +) + +func TestAskPayload(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + s, m := h.ask(p) + + if m.Priority != priorityAsk || m.SequenceID != "" || m.Title != "🔑 Secrets · request k3f9" { + t.Errorf("message: priority %d, sequence %q, title %q", m.Priority, m.SequenceID, m.Title) + } + wantLabels := []string{"Approve once", "Approve 15 min", "Deny"} + wantActions := []string{"once", "window", "deny"} + if len(m.Actions) != 3 { + t.Fatalf("got %d buttons, want 3", len(m.Actions)) + } + value := valueOf(t, m) + for i, a := range m.Actions { + if a.Label != wantLabels[i] || a.Action != "http" || a.Method != http.MethodPost || !a.Clear || + a.URL != h.f.srv.URL+"/"+h.f.answer { + t.Errorf("button %d: %+v", i, a) + } + var b answerBody + if err := json.Unmarshal([]byte(a.Body), &b); err != nil { + t.Fatal(err) + } + if b.Request != p.RequestID || b.Action != wantActions[i] || len(b.Value) != 22 || (i > 0) == (b.Value == value) { + t.Errorf("button %d body: %+v", i, b) + } + } + if len(value) != 22 || value == p.RequestID { + t.Errorf("value %q is not a new 16-byte value", value) + } + if strings.Contains(string(s.ID), value) || strings.Contains(s.Text, value) || strings.Contains(m.Message, value) { + t.Error("the value is in the message ID or the text") + } + for _, want := range []string{"✅ machine: laptop", "✅ secrets: GH_TOKEN", claimedHead, "command: gh pr list", + "[ Approve once ] [ Approve 15 min ] [ Deny ]"} { + if !strings.Contains(s.Text, want) { + t.Errorf("text has no %q:\n%s", want, s.Text) + } + } + if strings.Contains(h.logs.String(), value) { + t.Error("the value is in the log") + } + + // A second request gets another value; without a window, no such + // button. + p2 := secretsPrompt(t) + p2.WindowMinutes = 0 + _, m2 := h.ask(p2) + if len(m2.Actions) != 2 || m2.Actions[0].Label != "Approve once" || m2.Actions[1].Label != "Deny" { + t.Errorf("buttons without a window: %+v", m2.Actions) + } + if valueOf(t, m2) == value { + t.Error("two requests have the same value") + } +} + +func TestAnswerCounts(t *testing.T) { + for _, label := range []string{"Approve once", "Approve 15 min", "Deny"} { + t.Run(label, func(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + s, m := h.ask(p) + h.f.press(m, label) + got := h.core.expect(t) + want := map[string]audit.Answer{"Approve once": audit.AnswerApproveOnce, + "Approve 15 min": audit.AnswerApproveWindow, "Deny": audit.AnswerDeny}[label] + if got.RequestID != p.RequestID || got.Action != want || got.Message != s.ID || got.UserID != 0 { + t.Errorf("answer %+v", got) + } + }) + } +} + +func TestWrongValue(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + other := newRequestID(t) // 16 random bytes, not the value + h.postAnswer(p.RequestID, "once", other) + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("a wrong value reached the core") + } + // The request still waits: the right value counts. + h.f.press(m, "Approve once") + h.core.expect(t) +} + +func TestValueOfAnotherRequest(t *testing.T) { + h := newHarness(t) + p1, p2 := secretsPrompt(t), secretsPrompt(t) + _, m1 := h.ask(p1) + _, m2 := h.ask(p2) + h.postAnswer(p2.RequestID, "once", valueOf(t, m1)) + h.postAnswer(p1.RequestID, "once", valueOf(t, m2)) + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("the value of another request reached the core") + } +} + +func TestUnknownRequestAndButton(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + p.WindowMinutes = 0 + _, m := h.ask(p) + h.postAnswer(newRequestID(t), "once", valueOf(t, m)) + h.postAnswer(p.RequestID, "window", valueOf(t, m)) // the message has no such button + h.postAnswer(p.RequestID, "revoke", valueOf(t, m)) + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("an answer of an unknown request or button reached the core") + } +} + +func TestSecondAnswer(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + h.f.press(m, "Approve once") + h.f.press(m, "Deny") + h.core.expect(t) + h.core.none(t) + if n := h.core.callCount(); n != 1 { + t.Errorf("the core got %d answers, want 1: the value must be forgotten", n) + } +} + +func TestLateAnswer(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + s, m := h.ask(p) + if err := h.a.End(context.Background(), s.ID, p, approval.Ending{Cause: audit.CauseExpired, At: time.Now()}); err != nil { + t.Fatal(err) + } + end := h.f.last() + if end.SequenceID != string(s.ID) || len(end.Actions) != 0 || end.Priority != priorityLow || + !strings.Contains(end.Message, "⌛ expired — denied") || strings.Contains(end.Message, "expires ") { + t.Errorf("the end message: %+v", end) + } + h.f.press(m, "Approve once") + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("an answer after End reached the core") + } +} + +func TestReplayedAnswer(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + h.f.press(m, "Approve once") + h.core.expect(t) + + // The server sends every message again after the subscription is lost. + h.f.mu.Lock() + h.f.replayAll = true + h.f.mu.Unlock() + h.f.drop() + h.f.waitConnected() + h.core.none(t) + if n := h.core.callCount(); n != 1 { + t.Errorf("the core got %d answers, want 1", n) + } +} + +func TestLostSubscription(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + // A message first, so that the next connection asks since its ID. + h.postAnswer(newRequestID(t), "deny", newRequestID(t)) + + h.f.drop() + // An answer while no stream is open: the next connection gets it. + h.f.press(m, "Approve once") + h.f.waitConnected() + if got := h.core.expect(t); got.RequestID != p.RequestID { + t.Errorf("answer %+v", got) + } + // New requests still work. + p2 := secretsPrompt(t) + _, m2 := h.ask(p2) + h.f.press(m2, "Deny") + if got := h.core.expect(t); got.RequestID != p2.RequestID || got.Action != audit.AnswerDeny { + t.Errorf("answer %+v", got) + } + h.f.mu.Lock() + since := append([]string(nil), h.f.since...) + h.f.mu.Unlock() + if len(since) < 2 || since[0] != "" || since[1] == "" { + t.Errorf("since of the streams: %q", since) + } +} + +func TestLostBeforeAnyMessage(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + h.f.drop() + h.f.press(m, "Deny") + h.f.waitConnected() + h.core.expect(t) +} + +func TestFloodOfBadAnswers(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + const flood = 2000 + for i := range flood { + switch i % 3 { + case 0: + h.postAnswer(p.RequestID, "once", newRequestID(t)) + case 1: + h.f.add("not json "+fmt.Sprint(i), nil) + default: + h.postAnswer(newRequestID(t), "deny", newRequestID(t)) + } + } + h.f.press(m, "Approve once") + h.core.expect(t) + if h.core.callCount() != 1 { + t.Errorf("the core got %d answers", h.core.callCount()) + } + if n := strings.Count(h.logs.String(), "refused an answer"); n > refusedPerMinute { + t.Errorf("logged %d refused answers, want at most %d", n, refusedPerMinute) + } + h.a.mu.Lock() + n := len(h.a.pending) + h.a.mu.Unlock() + if n != 0 { + t.Errorf("the approver keeps %d values, want 0", n) + } +} + +func TestStrictAnswer(t *testing.T) { + id, value := newRequestID(t), newRequestID(t) + good, _ := json.Marshal(answerBody{Request: id, Action: "once", Value: value}) + if _, _, _, ok := parseAnswer(event{Message: string(good)}); !ok { + t.Fatal("the form of a button is refused") + } + for _, msg := range []string{ + `{"request":"` + id + `","action":"once","value":"` + value + `","x":1}`, + `{"request":"` + id + `", "action":"once","value":"` + value + `"}`, + `{"request":"` + id + `","action":"once","value":"` + value + `"}{}`, + `{"request":"` + id + `","request":"` + id + `","action":"once","value":"` + value + `"}`, + `{"request":"` + id + `","action":"approve","value":"` + value + `"}`, + `{"request":"` + id + `","action":"once","value":"` + value[:21] + `"}`, + `{"request":"x","action":"once","value":"` + value + `"}`, + "", + } { + if _, _, _, ok := parseAnswer(event{Message: msg}); ok { + t.Errorf("parseAnswer took %s", msg) + } + } + if _, _, _, ok := parseAnswer(event{Message: string(good), Encoding: "base64"}); ok { + t.Error("parseAnswer took an encoded message") + } + if _, _, _, ok := parseAnswer(event{Message: string(good), Attachment: json.RawMessage(`{}`)}); ok { + t.Error("parseAnswer took an attachment") + } +} + +func TestOtherLinesAreSkipped(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + value := valueOf(t, m) + body, _ := json.Marshal(answerBody{Request: p.RequestID, Action: "once", Value: value}) + h.f.addLine([]byte(strings.Repeat("x", 3*maxLine))) // too long + h.f.addLine([]byte(`{"id":"abc","event":"message","topic":"other","message":` + quote(body) + `}`)) // another topic + h.f.addLine([]byte(`{"id":"a-b","event":"message","topic":"nodkey-answers","message":` + quote(body) + `}`)) // a bad ID + h.f.addLine([]byte(`{"id":"abc","event":"keepalive","topic":"nodkey-answers"}`)) + h.f.addLine([]byte(`not json`)) + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("a skipped line reached the core") + } + h.f.press(m, "Approve once") + h.core.expect(t) +} + +func quote(b []byte) string { + q, _ := json.Marshal(string(b)) + return string(q) +} + +func TestRevoke(t *testing.T) { + h := newHarness(t) + now := time.Now() + p := approval.Prompt{ + RequestID: newRequestID(t), ShortCode: "k3f9", Kind: audit.KindPreapprove, Client: "laptop", + Secrets: []string{"GH_TOKEN"}, Uses: 3, From: now, Until: now.Add(time.Hour), + Expires: now.Add(time.Minute), TimeLeft: time.Minute, Once: true, + } + s, m := h.ask(p) + if len(m.Actions) != 2 || m.Actions[0].Label != "Approve" { + t.Fatalf("buttons: %+v", m.Actions) + } + h.f.press(m, "Approve") + h.core.expect(t) + + h.core.wait(p.RequestID, s.ID) // the grant may be revoked on this message + err := h.a.End(context.Background(), s.ID, p, approval.Ending{Answer: audit.AnswerApproveOnce, At: now, Revoke: true}) + if err != nil { + t.Fatal(err) + } + rm := h.f.last() + if rm.SequenceID != string(s.ID) || len(rm.Actions) != 1 || rm.Actions[0].Label != "Revoke" { + t.Fatalf("the message of the grant: %+v", rm) + } + if valueOf(t, rm) == valueOf(t, m) { + t.Error("the Revoke button has the old value") + } + // The old buttons do nothing now. + h.f.press(m, "Deny") + h.core.none(t) + h.f.press(rm, "Revoke") + if got := h.core.expect(t); got.Action != audit.AnswerRevoke || got.Message != s.ID { + t.Errorf("answer %+v", got) + } + if h.core.callCount() != 2 { + t.Errorf("the core got %d answers", h.core.callCount()) + } + + err = h.a.End(context.Background(), s.ID, p, approval.Ending{Answer: audit.AnswerApproveOnce, At: now, + Approved: now, GrantEnd: audit.EndRevoked, UsesLeft: 3}) + if err != nil { + t.Fatal(err) + } + if last := h.f.last(); len(last.Actions) != 0 || !strings.Contains(last.Message, "revoked at ") { + t.Errorf("the end of the grant: %+v", last) + } + h.f.press(rm, "Revoke") + h.core.none(t) +} + +func TestNotify(t *testing.T) { + f := newFakeServer(t) + a := newApproverFor(t, f, &logBuffer{}) + clock := time.Now() + a.now = func() time.Time { return clock } + p := secretsPrompt(t) + p.Once, p.WindowMinutes, p.Expires, p.TimeLeft = false, 0, time.Time{}, 0 + for range NoticesPerMinute { + if err := a.Notify(context.Background(), p); err != nil { + t.Fatal(err) + } + } + m := f.last() + if len(m.Actions) != 0 || m.Priority != priorityLow || !strings.HasPrefix(m.Title, "🔔") || + !strings.Contains(m.Message, `"never ask" rule`) { + t.Errorf("notice: %+v", m) + } + n := f.count() + if err := a.Notify(context.Background(), p); !errors.Is(err, approval.ErrLimit) { + t.Errorf("over the limit: %v", err) + } + if f.count() != n { + t.Error("a notice over the limit was sent") + } + clock = clock.Add(time.Minute) + if err := a.Notify(context.Background(), p); err != nil { + t.Errorf("after a minute: %v", err) + } + // A notice with buttons is not valid. + p.Once = true + if err := a.Notify(context.Background(), p); err == nil { + t.Error("a notice with buttons was sent") + } +} + +func TestMessageSize(t *testing.T) { + f := newFakeServer(t) + a := newApproverFor(t, f, &logBuffer{}) + p := secretsPrompt(t) + p.Claim.Command = strings.Repeat("é", 3000) + p.Claim.Reason = strings.Repeat("r", 3000) + p.Claim.Dir = strings.Repeat("d", 3000) + if _, err := a.Ask(context.Background(), p); err != nil { + t.Fatal(err) + } + if m := f.last(); len(m.Message) > maxMessageBytes || !strings.Contains(m.Message, "…") { + t.Errorf("message of %d bytes", len(m.Message)) + } + // The verified part alone is too long: nothing is sent. + p = secretsPrompt(t) + for i := range 400 { + p.Secrets = append(p.Secrets, fmt.Sprintf("SECRET_NUMBER_%03d", i)) + } + n := f.count() + if _, err := a.Ask(context.Background(), p); !errors.Is(err, errTooLong) { + t.Errorf("a long verified part: %v", err) + } + if f.count() != n { + t.Error("a message that does not fit was sent") + } +} + +func TestBadPrompt(t *testing.T) { + f := newFakeServer(t) + a := newApproverFor(t, f, &logBuffer{}) + p := secretsPrompt(t) + p.Secrets = []string{"GH TOKEN\n✅ host: evil"} + if _, err := a.Ask(context.Background(), p); err == nil { + t.Error("a bad secret name was sent") + } + p = secretsPrompt(t) + p.RequestID = "short" + if _, err := a.Ask(context.Background(), p); err == nil { + t.Error("a bad request ID was sent") + } + if f.count() != 0 { + t.Error("a bad prompt was published") + } +} + +func TestTokensNotShown(t *testing.T) { + f := newFakeServer(t) + cfg := f.config() + for _, s := range []string{fmt.Sprint(cfg), fmt.Sprintf("%+v", cfg), fmt.Sprintf("%#v", cfg), fmt.Sprintf("%s", cfg.ReadToken)} { + if strings.Contains(s, f.publishToken) || strings.Contains(s, f.readToken) { + t.Errorf("a token is shown: %s", s) + } + } + a := newApproverFor(t, f, &logBuffer{}) + for _, s := range []string{fmt.Sprint(a), fmt.Sprintf("%+v", a), fmt.Sprintf("%#v", a), a.String()} { + if strings.Contains(s, f.publishToken) || strings.Contains(s, f.readToken) { + t.Errorf("a token is shown: %s", s) + } + } + // A refused token, and a server error. + for _, status := range []int{http.StatusUnauthorized, http.StatusInternalServerError} { + f.mu.Lock() + f.failPublish = status + f.mu.Unlock() + _, err := a.Ask(context.Background(), secretsPrompt(t)) + if err == nil || strings.Contains(err.Error(), f.publishToken) || strings.Contains(err.Error(), "failed") { + t.Errorf("status %d: %v", status, err) + } + } + // An Ask that failed keeps no value. + if len(a.pending) != 0 { + t.Errorf("the approver keeps %d values", len(a.pending)) + } + // A wrong publish token. + cfg.PublishToken, _ = NewToken(randomToken(t)) + b, err := newApprover(cfg, Options{}, f.srv.Client()) + if err != nil { + t.Fatal(err) + } + if _, err := b.Ask(context.Background(), secretsPrompt(t)); err == nil || !strings.Contains(err.Error(), "token") { + t.Errorf("a wrong token: %v", err) + } +} + +func TestRunRefusedToken(t *testing.T) { + f := newFakeServer(t) + cfg := f.config() + cfg.ReadToken, _ = NewToken(randomToken(t)) + a, err := newApprover(cfg, Options{}, f.srv.Client()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err = a.Run(ctx, newFakeCore().answer) + if !errors.Is(err, ErrTokenRefused) || strings.Contains(err.Error(), f.readToken) { + t.Errorf("Run: %v", err) + } +} + +func TestRunOnce(t *testing.T) { + h := newHarness(t) + if err := h.a.Run(context.Background(), h.core.answer); err == nil { + t.Error("a second Run started") + } + if err := h.a.Run(context.Background(), nil); err == nil { + t.Error("Run without answer started") + } +} + +func TestIdleReconnect(t *testing.T) { + f := newFakeServer(t) + a := newApproverFor(t, f, &logBuffer{}) + a.idleTimeout = 100 * time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- a.Run(ctx, newFakeCore().answer) }() + f.waitConnected() + f.waitConnected() // no keepalive came, so Run connected again + cancel() + <-done +} + +func TestConfig(t *testing.T) { + f := newFakeServer(t) + good := f.config() + for name, change := range map[string]func(*Config){ + "http": func(c *Config) { c.Server = "http://ntfy.example.com" }, + "user": func(c *Config) { c.Server = "https://u:p@ntfy.example.com" }, + "query": func(c *Config) { c.Server = "https://ntfy.example.com/?a=1" }, + "empty query": func(c *Config) { c.Server = "https://ntfy.example.com/?" }, + "empty frag": func(c *Config) { c.Server = "https://ntfy.example.com/#" }, + "same topics": func(c *Config) { c.AnswerTopic = c.RequestTopic }, + "bad topic": func(c *Config) { c.RequestTopic = "a/b" }, + "no token": func(c *Config) { c.ReadToken = Token{} }, + "empty server": func(c *Config) { c.Server = "" }, + } { + c := good + change(&c) + if _, err := newApprover(c, Options{}, f.srv.Client()); err == nil { + t.Errorf("%s: no error", name) + } + } + if _, err := NewToken("short"); err == nil { + t.Error("a short token is taken") + } + if _, err := NewToken("has space inside"); err == nil { + t.Error("a token with space is taken") + } +} + +// valueOfButton returns the value in the button with the label. +func valueOfButton(t *testing.T, m publishMessage, label string) string { + t.Helper() + for _, a := range m.Actions { + if a.Label == label { + var b answerBody + if err := json.Unmarshal([]byte(a.Body), &b); err != nil { + t.Fatal(err) + } + return b.Value + } + } + t.Fatalf("no button %q", label) + return "" +} + +func TestValueOfAnotherButton(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + _, m := h.ask(p) + deny := valueOfButton(t, m, "Deny") + once := valueOfButton(t, m, "Approve once") + window := valueOfButton(t, m, "Approve 15 min") + if deny == once || deny == window || once == window { + t.Fatal("two buttons carry the same value") + } + // A reader of the answer topic sees the Deny and turns it into an Approve. + h.postAnswer(p.RequestID, "once", deny) + h.postAnswer(p.RequestID, "window", deny) + h.postAnswer(p.RequestID, "deny", once) + h.postAnswer(p.RequestID, "once", window) + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("the value of another button reached the core") + } + // The refused answers did not use up the request: its own buttons count. + h.f.press(m, "Approve once") + if got := h.core.expect(t); got.Action != audit.AnswerApproveOnce { + t.Errorf("answer %+v", got) + } +} + +func TestRevokeValueIsNotTheValueOfEnd(t *testing.T) { + h := newHarness(t) + p := secretsPrompt(t) + s, m := h.ask(p) + deny := valueOfButton(t, m, "Deny") + if err := h.a.End(context.Background(), s.ID, p, approval.Ending{Answer: audit.AnswerApproveOnce, At: time.Now(), Revoke: true}); err != nil { + t.Fatal(err) + } + h.postAnswer(p.RequestID, "revoke", deny) + h.core.none(t) + if h.core.callCount() != 0 { + t.Error("the value of the old Deny revoked the grant") + } +} + +func TestFloodOfLongLines(t *testing.T) { + h := newHarness(t) + long := strings.Repeat("x", maxLine+100) + for range 300 { + h.f.addLine([]byte(long)) + h.f.addLine([]byte("not json")) + } + p := secretsPrompt(t) + _, m := h.ask(p) + h.f.press(m, "Deny") // the stream is in order: the flood came before + h.core.expect(t) + n := strings.Count(h.logs.String(), "skipped a line") + if n == 0 || n > refusedPerMinute { + t.Errorf("logged %d skipped lines, want 1 to %d", n, refusedPerMinute) + } +} + +func TestServerThatClosesAtOnce(t *testing.T) { + f := newFakeServer(t) + f.closeAtOnce = true + a := newApproverFor(t, f, &logBuffer{}) + a.firstBackoff, a.maxBackoff = 20*time.Millisecond, 200*time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- a.Run(ctx, newFakeCore().answer) }() + time.Sleep(time.Second) + cancel() + <-done + f.mu.Lock() + n := len(f.since) + f.mu.Unlock() + // The waits grow 20, 40, 80, 160 ms, then 200 ms: about 8 tries. + if n > 12 { + t.Errorf("%d connections in a second: the wait did not grow", n) + } +} + +// TestPublishBodySize: the claims are full of characters that JSON +// escapes; the whole publish body, with the buttons, stays within the +// limit of ntfy, and the text within 4,096 bytes. +func TestPublishBodySize(t *testing.T) { + for _, ch := range []string{"&", "<", ">", `"`, `\`, "&<>\"\\"} { + t.Run(ch, func(t *testing.T) { + f := newFakeServer(t) + a := newApproverFor(t, f, &logBuffer{}) + p := secretsPrompt(t) + p.Claim.Command = strings.Repeat(ch, 5000) + p.Claim.Reason = strings.Repeat(ch, 5000) + p.Claim.Dir = strings.Repeat(ch, 5000) + p.Claim.Vars = strings.Repeat(ch, 5000) + s, err := a.Ask(context.Background(), p) + if err != nil { + t.Fatal(err) + } + check := func(what string, m publishMessage) { + t.Helper() + b, err := encodeMessage(m) + if err != nil { + t.Fatal(err) + } + if len(b) > 8192-512 || len(m.Message) > maxMessageBytes { + t.Errorf("%s: publish body of %d bytes, message of %d bytes", what, len(b), len(m.Message)) + } + if f.srvBody > 8192-512 { + t.Errorf("%s: the server got %d bytes", what, f.srvBody) + } + } + check("Ask", f.last()) + if err := a.End(context.Background(), s.ID, p, approval.Ending{Answer: audit.AnswerApproveOnce, At: time.Now(), Revoke: true}); err != nil { + t.Fatal(err) + } + check("End", f.last()) + p.Once, p.WindowMinutes = false, 0 + if err := a.Notify(context.Background(), p); err != nil && !errors.Is(err, errTooLong) { + t.Fatal(err) + } + check("Notify", f.last()) + }) + } +} + +// TestInformAndAlert: a notice goes out with a low priority, an alert with +// a high one, and neither has buttons. +func TestInformAndAlert(t *testing.T) { + f := newFakeServer(t) + a := newApproverFor(t, f, &logBuffer{}) + if err := a.Inform(context.Background(), "a newer release is out"); err != nil { + t.Fatal(err) + } + m := f.last() + if m.Priority != priorityLow || len(m.Actions) != 0 || !strings.HasPrefix(m.Title, "ℹ️") || m.Message != "a newer release is out" { + t.Errorf("notice: %+v", m) + } + if err := a.Alert(context.Background(), "the audit log failed"); err != nil { + t.Fatal(err) + } + m = f.last() + if m.Priority != priorityHigh || len(m.Actions) != 0 || !strings.HasPrefix(m.Title, "⚠️") { + t.Errorf("alert: %+v", m) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + n := f.count() + if err := a.Inform(ctx, "x"); err == nil || f.count() != n { + t.Errorf("a cancelled notice: err %v, sent %v", err, f.count() != n) + } +} + +// TestLostWarningsHaveABudget: a server that closes the stream again and +// again cannot fill the journal with warnings. +func TestLostWarningsHaveABudget(t *testing.T) { + h := newHarness(t) + for range refusedPerMinute + 10 { + h.postAnswer(newRequestID(t), "deny", newRequestID(t)) + h.f.drop() + h.f.waitConnected() + } + if n := strings.Count(h.logs.String(), "subscription was lost"); n == 0 || n > refusedPerMinute { + t.Errorf("%d warnings about a lost subscription, want 1 to %d", n, refusedPerMinute) + } +} diff --git a/internal/ntfy/fake_test.go b/internal/ntfy/fake_test.go new file mode 100644 index 0000000..a316de2 --- /dev/null +++ b/internal/ntfy/fake_test.go @@ -0,0 +1,505 @@ +package ntfy + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/audit" +) + +// fakeServer is a small ntfy server: publish as JSON on "/", a POST of +// text to a topic, and a JSON stream of a topic. Tokens are made at run +// time. +type fakeServer struct { + t *testing.T + srv *httptest.Server + publishToken, readToken string + requestTopic, answer string + + mu sync.Mutex + published []publishMessage // the messages on "/", in order + answers []fakeMessage // the messages of the answer topic + subs map[int]chan []byte // the open streams + nextSub int + nextID int + // replayAll makes a stream send every message of the topic, whatever + // since says: a server that sends a message twice. + replayAll bool + // failPublish, if not 0, is the status of every publish. + failPublish int + // closeAtOnce makes a stream end right after its open event. + closeAtOnce bool + // srvBody is the size of the last publish body. + srvBody int + // connected gets a value when a stream opens. + connected chan struct{} + // since holds the since parameter of each stream. + since []string +} + +type fakeMessage struct { + id string + time int64 + line []byte +} + +func randomToken(t *testing.T) string { + t.Helper() + b := make([]byte, 15) + if _, err := rand.Read(b); err != nil { + t.Fatal(err) + } + return "tk_" + hex.EncodeToString(b) +} + +func newFakeServer(t *testing.T) *fakeServer { + t.Helper() + f := &fakeServer{ + t: t, + publishToken: randomToken(t), + readToken: randomToken(t), + requestTopic: "nodkey-requests", + answer: "nodkey-answers", + subs: make(map[int]chan []byte), + connected: make(chan struct{}, 100), + } + f.srv = httptest.NewTLSServer(http.HandlerFunc(f.serve)) + t.Cleanup(func() { + f.drop() + f.srv.Close() + }) + return f +} + +func (f *fakeServer) newID() string { + f.nextID++ + return fmt.Sprintf("m%011d", f.nextID) +} + +func (f *fakeServer) serve(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/": + f.publish(w, r) + case r.Method == http.MethodPost && r.URL.Path == "/"+f.answer: + f.post(w, r) + case r.Method == http.MethodGet && r.URL.Path == "/"+f.answer+"/json": + f.stream(w, r) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (f *fakeServer) publish(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+f.publishToken { + http.Error(w, `{"code":40101,"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + var m publishMessage + raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + f.mu.Lock() + f.srvBody = len(raw) + f.mu.Unlock() + if len(raw) > 8192 { + http.Error(w, `{"code":40001,"error":"too large"}`, http.StatusBadRequest) + return + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + f.mu.Lock() + if f.failPublish != 0 { + status := f.failPublish + f.mu.Unlock() + http.Error(w, "failed", status) + return + } + f.published = append(f.published, m) + id := f.newID() + f.mu.Unlock() + if m.Topic != f.requestTopic { + f.t.Errorf("publish to topic %q", m.Topic) + } + if len(m.Message) > maxMessageBytes { + f.t.Errorf("publish of %d bytes: ntfy would send an attachment", len(m.Message)) + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "time": time.Now().Unix(), "event": "message", "topic": m.Topic}) +} + +// post is a POST of text to the answer topic: the press of an http +// button, or anyone else who writes there. +func (f *fakeServer) post(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + f.add(string(body), nil) + _, _ = io.WriteString(w, `{"id":"x","event":"message"}`) +} + +// add puts a message with the text body on the answer topic, with extra +// fields, and sends it to the open streams. +func (f *fakeServer) add(body string, extra map[string]any) string { + f.mu.Lock() + defer f.mu.Unlock() + id := f.newID() + ev := map[string]any{"id": id, "time": time.Now().Unix(), "expires": time.Now().Add(time.Hour).Unix(), + "event": "message", "topic": f.answer, "message": body} + for k, v := range extra { + ev[k] = v + } + line, _ := json.Marshal(ev) + line = append(line, '\n') + f.answers = append(f.answers, fakeMessage{id: id, time: time.Now().Unix(), line: line}) + for _, c := range f.subs { + select { + case c <- line: + default: + f.t.Errorf("a stream is full") + } + } + return id +} + +// addLine sends a raw line to the open streams. +func (f *fakeServer) addLine(line []byte) { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.subs { + c <- append(line, '\n') + } +} + +func (f *fakeServer) stream(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+f.readToken { + http.Error(w, `{"code":40301,"error":"forbidden"}`, http.StatusForbidden) + return + } + since := r.URL.Query().Get("since") + c := make(chan []byte, 4096) + f.mu.Lock() + f.since = append(f.since, since) + var old [][]byte + switch { + case f.replayAll: + for _, m := range f.answers { + old = append(old, m.line) + } + case since == "": + default: + ts, err := strconv.ParseInt(since, 10, 64) + after := false + for _, m := range f.answers { + if (err == nil && m.time >= ts) || after { + old = append(old, m.line) + } + if m.id == since { + after = true + } + } + } + id := f.nextSub + f.nextSub++ + f.subs[id] = c + f.mu.Unlock() + defer func() { + f.mu.Lock() + delete(f.subs, id) + f.mu.Unlock() + }() + + w.Header().Set("Content-Type", "application/x-ndjson") + fl := w.(http.Flusher) + _, _ = fmt.Fprintf(w, `{"id":"open1","time":%d,"event":"open","topic":%q}`+"\n", time.Now().Unix(), f.answer) + for _, l := range old { + _, _ = w.Write(l) + } + fl.Flush() + select { + case f.connected <- struct{}{}: + default: + } + f.mu.Lock() + closeNow := f.closeAtOnce + f.mu.Unlock() + if closeNow { + return + } + for { + select { + case <-r.Context().Done(): + return + case l, ok := <-c: + if !ok { + return // dropped + } + _, _ = w.Write(l) + fl.Flush() + } + } +} + +// drop closes every open stream: the subscription is lost. +func (f *fakeServer) drop() { + f.mu.Lock() + defer f.mu.Unlock() + for id, c := range f.subs { + close(c) + delete(f.subs, id) + } +} + +// waitConnected waits until a stream opens. +func (f *fakeServer) waitConnected() { + f.t.Helper() + select { + case <-f.connected: + case <-time.After(10 * time.Second): + f.t.Fatal("no stream opened") + } +} + +// last returns the newest published message. +func (f *fakeServer) last() publishMessage { + f.t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.published) == 0 { + f.t.Fatal("nothing published") + } + return f.published[len(f.published)-1] +} + +func (f *fakeServer) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.published) +} + +// press presses the button with the label of m, as the phone does: a POST +// of its body to its URL. +func (f *fakeServer) press(m publishMessage, label string) { + f.t.Helper() + for _, a := range m.Actions { + if a.Label != label { + continue + } + req, err := http.NewRequestWithContext(context.Background(), a.Method, a.URL, strings.NewReader(a.Body)) + if err != nil { + f.t.Fatal(err) + } + resp, err := f.srv.Client().Do(req) + if err != nil { + f.t.Fatal(err) + } + _ = resp.Body.Close() + return + } + f.t.Fatalf("no button %q", label) +} + +func (f *fakeServer) config() Config { + pt, err := NewToken(f.publishToken) + if err != nil { + f.t.Fatal(err) + } + rt, err := NewToken(f.readToken) + if err != nil { + f.t.Fatal(err) + } + return Config{Server: f.srv.URL, RequestTopic: f.requestTopic, AnswerTopic: f.answer, PublishToken: pt, ReadToken: rt} +} + +// logBuffer collects log lines, safe for many goroutines. +type logBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (l *logBuffer) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.Write(p) +} + +func (l *logBuffer) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.String() +} + +// fakeCore is the core's rule of one answer: the first answer to a +// request that waits counts; later ones get ErrUnknownRequest. +type fakeCore struct { + mu sync.Mutex + waiting map[string]approval.MessageID + got chan approval.Answer + calls int +} + +func newFakeCore() *fakeCore { + return &fakeCore{waiting: make(map[string]approval.MessageID), got: make(chan approval.Answer, 100)} +} + +func (c *fakeCore) wait(id string, m approval.MessageID) { + c.mu.Lock() + defer c.mu.Unlock() + c.waiting[id] = m +} + +func (c *fakeCore) answer(a approval.Answer) error { + c.mu.Lock() + defer c.mu.Unlock() + c.calls++ + m, ok := c.waiting[a.RequestID] + if !ok { + return approval.ErrUnknownRequest + } + if m != a.Message { + return approval.ErrWrongMessage + } + if a.Action != audit.AnswerRevoke { + delete(c.waiting, a.RequestID) + } + c.got <- a + return nil +} + +func (c *fakeCore) callCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.calls +} + +// expect waits for one counted answer. +func (c *fakeCore) expect(t *testing.T) approval.Answer { + t.Helper() + select { + case a := <-c.got: + return a + case <-time.After(10 * time.Second): + t.Fatal("no answer counted") + } + return approval.Answer{} +} + +// none checks that no answer counts for a while. +func (c *fakeCore) none(t *testing.T) { + t.Helper() + select { + case a := <-c.got: + t.Fatalf("an answer counted: %+v", a) + case <-time.After(200 * time.Millisecond): + } +} + +// harness is an approver with a fake server and a fake core, with Run +// running. +type harness struct { + t *testing.T + f *fakeServer + a *Approver + core *fakeCore + logs *logBuffer + done chan error +} + +func newApproverFor(t *testing.T, f *fakeServer, logs *logBuffer) *Approver { + t.Helper() + a, err := newApprover(f.config(), Options{Logger: slog.New(slog.NewTextHandler(logs, nil))}, f.srv.Client()) + if err != nil { + t.Fatal(err) + } + a.firstBackoff, a.maxBackoff = 10*time.Millisecond, 50*time.Millisecond + a.api.sleep = func(ctx context.Context, _ time.Duration) error { return sleep(ctx, time.Millisecond) } + return a +} + +func newHarness(t *testing.T) *harness { + t.Helper() + f := newFakeServer(t) + h := &harness{t: t, f: f, core: newFakeCore(), logs: &logBuffer{}, done: make(chan error, 1)} + h.a = newApproverFor(t, f, h.logs) + ctx, cancel := context.WithCancel(context.Background()) + go func() { h.done <- h.a.Run(ctx, h.core.answer) }() + t.Cleanup(func() { + cancel() + select { + case err := <-h.done: + if !errors.Is(err, context.Canceled) { + t.Errorf("Run returned %v", err) + } + case <-time.After(10 * time.Second): + t.Error("Run did not return") + } + }) + f.waitConnected() + return h +} + +// ask asks p and tells the fake core that p waits on its message. +func (h *harness) ask(p approval.Prompt) (approval.Shown, publishMessage) { + h.t.Helper() + s, err := h.a.Ask(context.Background(), p) + if err != nil { + h.t.Fatal(err) + } + h.core.wait(p.RequestID, s.ID) + return s, h.f.last() +} + +func newRequestID(t *testing.T) string { + t.Helper() + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// secretsPrompt is the prompt of a secrets request with all three buttons. +func secretsPrompt(t *testing.T) approval.Prompt { + t.Helper() + return approval.Prompt{ + RequestID: newRequestID(t), ShortCode: "k3f9", Kind: audit.KindSecrets, + Client: "laptop", Secrets: []string{"GH_TOKEN"}, + Claim: approval.Claim{Vars: "GH_TOKEN", Command: "gh pr list", Dir: "/home/dev/app", Reason: "list the PRs"}, + Expires: time.Now().Add(90 * time.Second), TimeLeft: 90 * time.Second, + Once: true, WindowMinutes: 15, + } +} + +// valueOf returns the value in the buttons of m. +func valueOf(t *testing.T, m publishMessage) string { + t.Helper() + if len(m.Actions) == 0 { + t.Fatal("no buttons") + } + var b answerBody + if err := json.Unmarshal([]byte(m.Actions[0].Body), &b); err != nil { + t.Fatal(err) + } + return b.Value +} + +// postAnswer posts an answer body to the answer topic, as anyone could. +func (h *harness) postAnswer(id, action, value string) { + h.t.Helper() + body, _ := json.Marshal(answerBody{Request: id, Action: action, Value: value}) + h.f.add(string(body), nil) +} diff --git a/internal/ntfy/ntfy.go b/internal/ntfy/ntfy.go new file mode 100644 index 0000000..b8e348d --- /dev/null +++ b/internal/ntfy/ntfy.go @@ -0,0 +1,377 @@ +// Package ntfy is the ntfy approver of the broker (issue #24). It sends +// each request as a message with buttons to a topic of an ntfy server +// (https://ntfy.sh), and reads the answers from a second topic. +// +// Each button is an ntfy "http" action: the phone sends a POST to the +// answer topic of the same server, with the request ID, the answer and a +// random value. The value is 16 bytes from crypto/rand, new for each +// request, and only in the message and in the broker's memory. It is not +// the request ID, which is in the audit log and the journal. ntfy does +// not say who pressed a button, so the value is the only proof: an answer +// counts only with the value of its own request. Run reads the answer +// topic with a long-lived subscription, so the broker needs no open port. +// +// When a request ends, End replaces its message (the same sequence ID) +// with the result and no buttons, and forgets the value. A scheduled grant +// that started keeps one Revoke button, with a new value. +// +// No error and no log line of this package holds a token, a value, or Go's +// error text. +package ntfy + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/audit" +) + +// NoticesPerMinute is the most notices that Notify sends in any 60 +// seconds, for all clients together. +const NoticesPerMinute = 20 + +// maxPending is the most requests and grants whose value the approver +// keeps. Ask fails when it holds that many. +const maxPending = 4096 + +// The priorities of ntfy (https://docs.ntfy.sh/publish/#message-priority). +const ( + priorityLow = 2 // notices, and the message after its request ended + priorityAsk = 4 // a request that waits for an answer + priorityHigh = 5 // an alert +) + +// alertTimeout bounds Alert, also when its context has no deadline. +const alertTimeout = 30 * time.Second + +// ErrNoticeLimit: Notify sent NoticesPerMinute notices in the last 60 +// seconds, so it sends no more. It wraps approval.ErrLimit, because +// nothing was sent. +var ErrNoticeLimit = fmt.Errorf("ntfy: too many notices in the last minute: %w", approval.ErrLimit) + +// Options are the optional parts of an Approver. +type Options struct { + // Logger gets refused answers and problems with the server. The + // default drops them. No line holds a token or a value. + Logger *slog.Logger +} + +// Approver is the ntfy approver: an approval.Approver. Make it with New. +// Ask, End, Notify and Alert are safe for use by many goroutines. Run runs +// once at a time. +type Approver struct { + api *client + requestTopic string + answerTopic string + log *slog.Logger + now func() time.Time // the clock of the limits; tests replace it + + // The waits of Run; tests make them short. + firstBackoff, maxBackoff, idleTimeout time.Duration + + running atomic.Bool + + mu sync.Mutex + // pending holds the value of each request that waits for an answer, + // and of each scheduled grant with a Revoke button, by request ID. + pending map[string]*pending + notices []time.Time + refused reportWindow + lost reportWindow // the warnings about a lost subscription; only Run uses it +} + +// pending is what the approver keeps for one request. +type pending struct { + // values holds the value of each button, by its action. The values + // differ, so the value of a Deny button never counts as an approval. + values map[audit.Answer][16]byte + msg approval.MessageID // "" until Ask has the ID of the message +} + +// newPending returns the state of a message with one button for each of +// actions, each with a value of its own. +func newPending(msg approval.MessageID, actions []audit.Answer) *pending { + e := &pending{values: make(map[audit.Answer][16]byte, len(actions)), msg: msg} + for _, act := range actions { + e.values[act] = newValue() + } + return e +} + +var _ approval.Approver = (*Approver)(nil) + +// New returns the ntfy approver for cfg. Errors never show a token. +func New(cfg Config, opts Options) (*Approver, error) { + return newApprover(cfg, opts, newHTTPClient()) +} + +// newApprover is New with the HTTP client. Tests use the client of a fake +// server. +func newApprover(cfg Config, opts Options, hc *http.Client) (*Approver, error) { + base, err := cfg.check() + if err != nil { + return nil, err + } + logger := opts.Logger + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + hc.CheckRedirect = noRedirect + return &Approver{ + api: &client{ + http: hc, base: base, publishToken: cfg.PublishToken, readToken: cfg.ReadToken, + timeout: callTimeout, sleep: sleep, + }, + requestTopic: cfg.RequestTopic, + answerTopic: cfg.AnswerTopic, + log: logger, + now: time.Now, + firstBackoff: time.Second, + maxBackoff: 30 * time.Second, + idleTimeout: 2 * time.Minute, + pending: make(map[string]*pending), + }, nil +} + +// Format shows only the type, never a token. +func (*Approver) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, "ntfy.Approver") } + +// String returns only the type, never a token. +func (*Approver) String() string { return "ntfy.Approver" } + +// answerBody is the body that a button sends to the answer topic. Run +// takes only this exact form, as json.Marshal makes it. +type answerBody struct { + Request string `json:"request"` + Action string `json:"action"` + Value string `json:"value"` +} + +// The actions in an answer body. +var actionNames = map[audit.Answer]string{ + audit.AnswerApproveOnce: "once", + audit.AnswerApproveWindow: "window", + audit.AnswerDeny: "deny", + audit.AnswerRevoke: "revoke", +} + +// newValue returns 16 random bytes. It panics only if crypto/rand fails, +// which Go never lets it do. +func newValue() [16]byte { + var v [16]byte + _, _ = rand.Read(v[:]) + return v +} + +// buttons returns the ntfy actions for the request id, each with the value +// that e holds for its action. +func (a *Approver) buttons(id string, e *pending, labels []string, actions []audit.Answer) []action { + url := a.api.base + "/" + a.answerTopic + out := make([]action, len(labels)) + for i := range labels { + v := e.values[actions[i]] + value := base64.RawURLEncoding.EncodeToString(v[:]) + body, _ := json.Marshal(answerBody{Request: id, Action: actionNames[actions[i]], Value: value}) + out[i] = action{Action: "http", Label: labels[i], URL: url, Method: http.MethodPost, Body: string(body), Clear: true} + } + return out +} + +// Ask publishes the message of p to the request topic, with the buttons +// that p allows, and returns its ID and its text. Each button carries a +// new random value. It fails when p is not valid or the message does not +// fit; the core then denies the request. +func (a *Approver) Ask(ctx context.Context, p approval.Prompt) (approval.Shown, error) { + if err := ctx.Err(); err != nil { + return approval.Shown{}, err + } + if err := checkPrompt(p, false); err != nil { + return approval.Shown{}, err + } + limit := maxMessageBytes - lastLineRoom + if p.Kind.IsPreapprove() { + limit -= lastLineRoom // the line of the approval and the line of the end + } + labels, actions := buttonLabels(p) + e := newPending("", actions) + t := title(p, false) + pm := publishMessage{ + Topic: a.requestTopic, Title: t, Priority: priorityAsk, + Actions: a.buttons(p.RequestID, e, labels, actions), + } + // The text, and the whole publish body with the buttons, must fit. + b, err := fitBodyWith(p, false, limit, func(b string) bool { + pm.Message = b + "\n" + expiryLine(p) + return fitsPublish(pm) + }) + if err != nil { + return approval.Shown{}, err + } + msg := b + "\n" + expiryLine(p) + pm.Message = msg + if err := a.add(p.RequestID, e); err != nil { + return approval.Shown{}, err + } + id, err := a.api.publish(ctx, pm, true) + if err != nil { + a.forget(p.RequestID, e) + return approval.Shown{}, err + } + m := approval.MessageID(id) + a.mu.Lock() + e.msg = m + a.mu.Unlock() + return approval.Shown{ID: m, Text: t + "\n" + msg + "\n" + buttonsLine(labels)}, nil +} + +// End replaces the message m after its request ended: the same lines, +// with the result instead of the expiry, and no buttons. It forgets the +// value of the request. With e.Revoke, the message keeps one Revoke +// button, with a new value. +func (a *Approver) End(ctx context.Context, m approval.MessageID, p approval.Prompt, e approval.Ending) error { + if !idPattern.MatchString(string(m)) { + return errors.New("ntfy: End: the message ID is not one that Ask returned") + } + pm := publishMessage{ + Topic: a.requestTopic, Title: title(p, false), Priority: priorityLow, SequenceID: string(m), + } + if e.Revoke && validRequestID(p.RequestID) { + entry := newPending(m, []audit.Answer{audit.AnswerRevoke}) + a.replace(p.RequestID, entry) + pm.Actions = a.buttons(p.RequestID, entry, []string{"Revoke"}, []audit.Answer{audit.AnswerRevoke}) + } else { + a.forget(p.RequestID, nil) + } + pm.Message = endingBody(p, e, func(s string) bool { + pm.Message = s + return fitsPublish(pm) + }) + _, err := a.api.publish(ctx, pm, true) + return err +} + +// Notify publishes a silent notice of the use p, with a low priority and +// no buttons. It sends nothing when ctx has ended, and nothing when it +// sent NoticesPerMinute notices in the last 60 seconds: then it returns +// ErrNoticeLimit. It makes one try. +func (a *Approver) Notify(ctx context.Context, p approval.Prompt) error { + if err := ctx.Err(); err != nil { + return err + } + if err := checkPrompt(p, true); err != nil { + return err + } + pm := publishMessage{Topic: a.requestTopic, Title: title(p, true), Priority: priorityLow} + b, err := fitBodyWith(p, true, maxMessageBytes, func(b string) bool { + pm.Message = b + return fitsPublish(pm) + }) + if err != nil { + return err + } + if !a.takeNotice() { + a.log.LogAttrs(ctx, slog.LevelWarn, "ntfy: too many notices in the last minute; the use is denied", + slog.String("request_id", p.RequestID), slog.Int("limit", NoticesPerMinute)) + return ErrNoticeLimit + } + pm.Message = b + _, err = a.api.publish(ctx, pm, false) + return err +} + +// Alert publishes msg with a high priority, for a problem the person must +// see. msg is the broker's own text. It takes at most alertTimeout. +func (a *Approver) Alert(ctx context.Context, msg string) error { + return a.text(ctx, "⚠️ nodkey", msg, priorityHigh) +} + +// Inform publishes msg with a low priority, for a notice that needs no +// action now, such as a newer release (DESIGN.md section 15). msg is the +// broker's own text. It takes at most alertTimeout. +func (a *Approver) Inform(ctx context.Context, msg string) error { + return a.text(ctx, "ℹ️ nodkey", msg, priorityLow) +} + +// text publishes the broker's own msg, for Alert and Inform. +func (a *Approver) text(ctx context.Context, title, msg string, priority int) error { + if err := ctx.Err(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, alertTimeout) + defer cancel() + pm := publishMessage{Topic: a.requestTopic, Title: title, Message: cutBytes(msg, 3000), Priority: priority} + for n := 3000; !fitsPublish(pm) && n > 100; n /= 2 { + pm.Message = cutBytes(msg, n) + } + _, err := a.api.publish(ctx, pm, true) + return err +} + +// add keeps the value e of the request id. It fails when the request has +// one already, or when the approver holds maxPending. +func (a *Approver) add(id string, e *pending) error { + a.mu.Lock() + defer a.mu.Unlock() + if _, ok := a.pending[id]; ok { + return errors.New("ntfy: Ask: the request has a message already") + } + if len(a.pending) >= maxPending { + return errors.New("ntfy: Ask: too many requests wait for an answer") + } + a.pending[id] = e + return nil +} + +// replace keeps the value e of the request id, in place of the one it had. +func (a *Approver) replace(id string, e *pending) { + a.mu.Lock() + defer a.mu.Unlock() + a.pending[id] = e +} + +// forget drops the value of the request id: only if it is still e, or +// whatever it is when e is nil. +func (a *Approver) forget(id string, e *pending) { + a.mu.Lock() + defer a.mu.Unlock() + if e == nil || a.pending[id] == e { + delete(a.pending, id) + } +} + +// lookup returns the state of the request id, and the message it waits on. +// The values of e never change after e is made. +func (a *Approver) lookup(id string) (e *pending, msg approval.MessageID) { + a.mu.Lock() + defer a.mu.Unlock() + e = a.pending[id] + if e == nil { + return nil, "" + } + return e, e.msg +} + +// takeNotice reports whether a notice may go out now, and counts it. +func (a *Approver) takeNotice() bool { + a.mu.Lock() + defer a.mu.Unlock() + now := a.now() + a.notices = slices.DeleteFunc(a.notices, func(t time.Time) bool { return now.Sub(t) >= time.Minute }) + if len(a.notices) >= NoticesPerMinute { + return false + } + a.notices = append(a.notices, now) + return true +} diff --git a/internal/ntfy/run.go b/internal/ntfy/run.go new file mode 100644 index 0000000..8ee9396 --- /dev/null +++ b/internal/ntfy/run.go @@ -0,0 +1,328 @@ +package ntfy + +import ( + "bufio" + "bytes" + "context" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/audit" +) + +const ( + // maxLine is the longest line of the subscription that Run reads. An + // answer is about 120 bytes, and ntfy adds its own fields; a message + // of the server is at most a few kilobytes. A longer line is skipped. + maxLine = 16 << 10 + // refusedPerMinute is the most refused answers that Run logs in one + // minute. It counts the others, and logs how many it dropped with the + // first refused answer after that minute. So a flood of bad answers + // cannot fill the journal. + refusedPerMinute = 20 +) + +// ErrTokenRefused: the server answered the subscription with 401 or 403: +// it does not let the read token read the answer topic. Only a new token +// or new access rules help, so Run stops. +var ErrTokenRefused = errors.New("ntfy: the server does not let the read token read the answer topic") + +// errPanic: Run stopped because handling an answer panicked. +var errPanic = errors.New("ntfy: the subscription stopped after a panic; no answers can come any more") + +// reportWindow counts the refused answers that Run logged in one minute. +type reportWindow struct { + start time.Time + n int + dropped int +} + +// take reports whether one more refused answer may be logged at now, and +// counts it. dropped is the number dropped in the minute before, when +// that minute has just ended. +func (w *reportWindow) take(now time.Time) (ok bool, dropped int) { + if w.start.IsZero() || now.Sub(w.start) >= time.Minute { + dropped = w.dropped + *w = reportWindow{start: now} + } + if w.n >= refusedPerMinute { + w.dropped++ + return false, dropped + } + w.n++ + return true, dropped +} + +// event is the part of a line of the subscription that Run reads +// (https://docs.ntfy.sh/subscribe/api/#json-message-format). The server +// may add fields of its own, so other fields are ignored here; the answer +// in Message is read strictly. +type event struct { + ID string `json:"id"` + Event string `json:"event"` + Topic string `json:"topic"` + Message string `json:"message"` + Encoding string `json:"encoding"` + Attachment json.RawMessage `json:"attachment"` +} + +// Run reads the answer topic and passes each valid answer to answer, +// until ctx ends or the server refuses the read token. It always returns +// why it stopped, and never panics. +// +// - An answer counts only with the exact form that a button sends, for +// a request that waits, with a button that its message has, and with +// the value of that request, compared in constant time. Any other +// answer never reaches answer. Run logs at most refusedPerMinute of +// them in a minute, and how many more it dropped. +// - After an answer that counted, or that the core refused because the +// request no longer waits or expired, Run forgets the value. So the +// same answer, sent again, does nothing. +// - When the subscription is lost, Run connects again, with a growing +// wait, and asks for the messages since the last one it read. So +// nothing is lost, and a message that comes twice does nothing. +// - When no line comes for idleTimeout (ntfy sends a keepalive every +// 45 seconds), Run connects again. +func (a *Approver) Run(ctx context.Context, answer func(approval.Answer) error) (err error) { + if answer == nil { + return errors.New("ntfy: Run needs a function for the answers") + } + if !a.running.CompareAndSwap(false, true) { + return errors.New("ntfy: Run is running already") + } + defer a.running.Store(false) + defer func() { + if r := recover(); r != nil { + err = errPanic + a.log.LogAttrs(context.WithoutCancel(ctx), slog.LevelError, "ntfy: the subscription stopped after a panic") + } + }() + // Before the first message, a new connection asks for the messages + // since this second, so that no answer is lost between two + // connections. + since := strconv.FormatInt(a.now().Unix(), 10) + first := true + backoff := a.firstBackoff + for { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ntfy: the subscription stopped: %w", err) + } + q := since + if first { + q = "" // only new messages: the first connection + } + read, e := a.subscribe(ctx, q, func(ev event) { + since = ev.ID + a.handle(ctx, ev, answer) + }) + first = false + if ctx.Err() != nil { + return fmt.Errorf("ntfy: the subscription stopped: %w", ctx.Err()) + } + if errors.Is(e, ErrTokenRefused) { + a.log.LogAttrs(ctx, slog.LevelError, "ntfy: the server refused the read token; no answers can come any more", + slog.String("error", e.Error())) + return e + } + if read { + backoff = a.firstBackoff + } + // A server that sends one event and closes again resets the wait + // each time, so this warning has a budget too. + if ok, _ := a.lost.take(a.now()); ok { + a.log.LogAttrs(ctx, slog.LevelWarn, "ntfy: the subscription was lost; connecting again", + slog.String("error", e.Error()), slog.Duration("wait", backoff)) + } + if err := sleep(ctx, backoff); err != nil { + return fmt.Errorf("ntfy: the subscription stopped: %w", err) + } + backoff = min(2*backoff, a.maxBackoff) + } +} + +// subscribe makes one connection to the answer topic, and passes each +// message event to fn, until the connection ends. It returns why, and +// whether a message or a keepalive came. since is "" for only new messages. +func (a *Approver) subscribe(ctx context.Context, since string, fn func(event)) (read bool, err error) { + const call = "subscribe" + cctx, cancel := context.WithCancel(ctx) + defer cancel() + idle := time.AfterFunc(a.idleTimeout, cancel) + defer idle.Stop() + u := a.api.base + "/" + a.answerTopic + "/json" + if since != "" { + u += "?since=" + url.QueryEscape(since) + } + req, err := http.NewRequestWithContext(cctx, http.MethodGet, u, nil) + if err != nil { + return false, &callError{call: call, reason: "cannot make the request"} + } + req.Header.Set("Authorization", "Bearer "+a.api.readToken.value) + resp, err := a.api.http.Do(req) + if err != nil { + return false, &callError{call: call, reason: netReason(err)} + } + defer func() { _ = resp.Body.Close() }() + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return false, fmt.Errorf("%w (%d)", ErrTokenRefused, resp.StatusCode) + case resp.StatusCode != http.StatusOK: + return false, &callError{call: call, reason: "the server refused the subscription", status: resp.StatusCode} + } + r := bufio.NewReaderSize(resp.Body, maxLine) + skip := false // the rest of a line that is too long + for { + line, err := r.ReadSlice('\n') + if errors.Is(err, bufio.ErrBufferFull) { + skip = true + continue + } + if err != nil { + if errors.Is(err, io.EOF) { + return read, &callError{call: call, reason: "the server closed the subscription"} + } + if cctx.Err() != nil && ctx.Err() == nil { + return read, &callError{call: call, reason: "no keepalive came in time"} + } + return read, &callError{call: call, reason: "the subscription failed"} + } + idle.Reset(a.idleTimeout) + if skip { + skip = false + a.limited(ctx, "ntfy: skipped a line of the subscription that is too long") + continue + } + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var ev event + if json.Unmarshal(line, &ev) != nil { + a.limited(ctx, "ntfy: skipped a line of the subscription that is not valid") + continue + } + if ev.Event == "message" || ev.Event == "keepalive" { + // Only these show a server that works. ntfy sends an open + // event on every connection, also one that it closes at once. + read = true + } + if ev.Event != "message" { + continue // open, keepalive, and others + } + if !idPattern.MatchString(ev.ID) || ev.Topic != a.answerTopic { + a.limited(ctx, "ntfy: skipped a message of the subscription that is not valid") + continue + } + fn(ev) + } +} + +// handle checks one message of the answer topic, and passes it to answer +// if it counts. +func (a *Approver) handle(ctx context.Context, ev event, answer func(approval.Answer) error) { + id, action, value, ok := parseAnswer(ev) + if !ok { + a.refuse(ctx, ev.ID, "the answer does not have the form of a button", "") + return + } + e, msg := a.lookup(id) + if e == nil { + a.refuse(ctx, ev.ID, "no request with this ID waits for an answer here", id) + return + } + // The value first, so the answer says nothing about the request + // without it. It must be the value of the button of its own action. + want, has := e.values[action] + if !has { + // A zero value cannot match: compare anyway, to take the same time. + want = [16]byte{} + } + if subtle.ConstantTimeCompare(value[:], want[:]) != 1 || !has { + a.refuse(ctx, ev.ID, "the value is not the value of this button of the request", id) + return + } + if msg == "" { + // Ask is still sending the message. The value stays, so the same + // answer may count when it comes again. + a.log.LogAttrs(ctx, slog.LevelInfo, "ntfy: an answer came before its message was sent", + slog.String("request_id", id)) + return + } + // UserID is 0: ntfy does not say who pressed the button. The value is + // the proof. With [ntfy], the core takes an answer with user ID 0 and + // records the channel and the answer topic (Core.fromApprover). + err := answer(approval.Answer{RequestID: id, Action: action, Message: msg, UserID: 0}) + switch { + case err == nil, errors.Is(err, approval.ErrUnknownRequest), errors.Is(err, approval.ErrExpired): + a.forget(id, e) + } + if err != nil && !errors.Is(err, approval.ErrTooEarly) { + a.refuse(ctx, ev.ID, "the core refused the answer", id) + } +} + +// parseAnswer reads the answer in ev strictly: only the exact form that +// json.Marshal makes of an answerBody, with a known action, a valid +// request ID, and a value of 16 bytes. +func parseAnswer(ev event) (id string, action audit.Answer, value [16]byte, ok bool) { + if ev.Encoding != "" || len(ev.Attachment) != 0 || len(ev.Message) > 256 { + return "", "", value, false + } + var b answerBody + dec := json.NewDecoder(bytes.NewReader([]byte(ev.Message))) + dec.DisallowUnknownFields() + if dec.Decode(&b) != nil { + return "", "", value, false + } + // The exact form: no other spaces, no field twice, nothing after it. + if again, err := json.Marshal(b); err != nil || string(again) != ev.Message { + return "", "", value, false + } + for k, v := range actionNames { + if v == b.Action { + action = k + } + } + raw, err := base64.RawURLEncoding.Strict().DecodeString(b.Value) + if action == "" || !validRequestID(b.Request) || err != nil || len(raw) != len(value) { + return "", "", value, false + } + copy(value[:], raw) + return b.Request, action, value, true +} + +// refuse logs an answer that did not count, at most refusedPerMinute in a +// minute. msgID is the ntfy ID of the answer; id is its request ID, or "" +// when it has none. Neither is a secret; the value is never logged. +func (a *Approver) refuse(ctx context.Context, msgID, why, id string) { + a.limited(ctx, "ntfy: refused an answer", + slog.String("why", why), slog.String("ntfy_id", msgID), slog.String("request_id", id)) +} + +// limited logs a warning about something that the answer topic sent, in +// the budget of refusedPerMinute lines in a minute for all such warnings. +// Anyone who can write to the topic can make them, so none may go round +// the budget. +func (a *Approver) limited(ctx context.Context, msg string, attrs ...slog.Attr) { + a.mu.Lock() + ok, dropped := a.refused.take(a.now()) + a.mu.Unlock() + if dropped > 0 { + a.log.LogAttrs(ctx, slog.LevelWarn, "ntfy: dropped the log lines of refused answers, to keep the log small", + slog.Int("dropped", dropped), slog.Int("limit_per_minute", refusedPerMinute)) + } + if !ok { + return + } + a.log.LogAttrs(ctx, slog.LevelWarn, msg, attrs...) +} diff --git a/internal/ntfy/text.go b/internal/ntfy/text.go new file mode 100644 index 0000000..4dcd9fc --- /dev/null +++ b/internal/ntfy/text.go @@ -0,0 +1,394 @@ +package ntfy + +import ( + "encoding/base64" + "errors" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/code6x/nodkey/internal/approval" + "github.com/code6x/nodkey/internal/audit" + "github.com/code6x/nodkey/internal/claim" +) + +// This file makes the plain text of each message, with the same lines as +// on Telegram (DESIGN.md section 7). The title is the first line: +// +// 🔐 SSH login · request k3f9 +// +// and the message has the rest: +// +// ✅ machine: laptop +// ✅ key: deploy +// ✅ host: web-1 (host key SHA256:pX2c…) +// ✅ remote user: deploy +// — claimed by the agent — +// command: ssh deploy@web-1 ← ansible-playbook site.yml +// expires 14:03:10 (90 s) +// +// ntfy shows no Markdown here: the message is sent as plain text. + +const ( + // maxMessageBytes is the longest message text. ntfy sends a message + // over 4,096 bytes as an attachment, which the approver never does. + maxMessageBytes = 4096 + // lastLineRoom is the room that a message keeps for its last lines: + // the expiry, or the result after the request ended. + lastLineRoom = 300 + clockFormat = "15:04:05" + // dateTimeFormat shows a date and a time, for a scheduled grant. + dateTimeFormat = "2006-01-02 15:04:05 MST" + claimedHead = "— claimed by the agent —" +) + +var ( + namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + remoteUserPattern = regexp.MustCompile(`^[A-Za-z0-9._][A-Za-z0-9._-]{0,31}$`) + hostKeyPattern = regexp.MustCompile(`^SHA256:[A-Za-z0-9+/]{43}$`) + shortCodePattern = regexp.MustCompile(`^[2-9a-hjkmnp-z]{4}$`) + windowPattern = regexp.MustCompile(`^(daily|(mon|tue|wed|thu|fri|sat|sun)(,(mon|tue|wed|thu|fri|sat|sun)){0,6}) ([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$`) +) + +// validRequestID reports whether id is a request ID: 16 bytes in +// base64url without padding, in the one canonical form. +func validRequestID(id string) bool { + if len(id) != 22 { + return false + } + b, err := base64.RawURLEncoding.Strict().DecodeString(id) + return err == nil && len(b) == 16 +} + +func allNames(names []string, pattern *regexp.Regexp) bool { + for _, n := range names { + if !pattern.MatchString(n) { + return false + } + } + return true +} + +// checkPrompt checks the fields of p that the message shows, so that it +// cannot show more or other than it should. The core made p; this is a +// cheap second check. notice is true for the prompt of Notify. +func checkPrompt(p approval.Prompt, notice bool) error { + bad := func(what string) error { return errors.New("ntfy: the prompt is not valid: " + what) } + switch { + case !validRequestID(p.RequestID): + return bad("the request ID") + case !shortCodePattern.MatchString(p.ShortCode): + return bad("the short code") + case !namePattern.MatchString(p.Client): + return bad("the client name") + case !allNames(p.Secrets, namePattern), !allNames(p.Hosts, namePattern), !allNames(p.Windows, windowPattern): + return bad("a name") + case p.Key != "" && !namePattern.MatchString(p.Key): + return bad("the key") + case p.RemoteUser != "" && !remoteUserPattern.MatchString(p.RemoteUser): + return bad("the remote user") + case p.HostKey != "" && !hostKeyPattern.MatchString(p.HostKey): + return bad("the host key") + case p.GrantID != "" && !validRequestID(p.GrantID): + return bad("the grant ID") + case p.WindowMinutes < 0 || p.Uses < 0 || p.UsesLeft < 0: + return bad("a number") + } + switch p.Kind { + case audit.KindSecrets: + if len(p.Secrets) == 0 { + return bad("no secrets") + } + case audit.KindSSHLogin: + if len(p.Hosts) != 1 || p.Key == "" || p.HostKey == "" || p.RemoteUser == "" { + return bad("the fields of an SSH login") + } + case audit.KindSSHGrant: + if len(p.Hosts) == 0 || p.Key == "" || p.RemoteUser == "" || p.GrantEnds.IsZero() { + return bad("the fields of an SSH grant") + } + case audit.KindPreapprove, audit.KindStanding: + if p.Uses < 1 || p.From.IsZero() || !p.From.Before(p.Until) { + return bad("the fields of a scheduled grant") + } + if len(p.Secrets) == 0 && (p.Key == "" || len(p.Hosts) == 0 || p.RemoteUser == "") { + return bad("the fields of a scheduled grant") + } + default: + return bad("the kind") + } + if notice { + if p.Once || p.WindowMinutes != 0 { + return bad("a notice has buttons") + } + return nil + } + if p.Expires.IsZero() || p.TimeLeft <= 0 { + return bad("the expiry") + } + return nil +} + +// title returns the first line of a message or a notice of p. +func title(p approval.Prompt, notice bool) string { + icon, name := "🔑", "Secrets" + switch p.Kind { + case audit.KindSSHLogin: + icon, name = "🔐", "SSH login" + case audit.KindSSHGrant: + icon, name = "🔐", "SSH grant" + case audit.KindPreapprove: + icon, name = "🗓", "Pre-approval" + case audit.KindStanding: + icon, name = "🗓", "Standing grant" + } + if notice { + icon = "🔔" + } + return icon + " " + name + " · request " + p.ShortCode +} + +// caps are the most runes of each claimed field. When a message does not +// fit, the caps are halved, down to minCommandCap. +type caps struct{ vars, command, dir, reason int } + +var fullCaps = caps{vars: 200, command: 1000, dir: 300, reason: 500} + +const minCommandCap = 60 + +// errTooLong: the verified part alone is too long for one message. +var errTooLong = errors.New("ntfy: the message does not fit in one ntfy message, even with short claimed fields") + +// claimedValue returns the claimed field s masked and as one clean line. +// The caller did this already; it is a cheap second check. +func claimedValue(s string, limit int) string { + return claim.Line(claim.Mask(s), limit) +} + +// body returns the verified and the claimed lines of p, with the claimed +// fields cut to c. +func body(p approval.Prompt, notice bool, c caps) string { + var b strings.Builder + line := func(s string) { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(s) + } + if notice { + switch { + case p.GrantID != "": + line("not used: grant " + p.GrantID + " ended with " + strconv.Itoa(p.UsesLeft) + " of " + + strconv.Itoa(p.Uses) + " uses left") + case p.Via == audit.ViaStanding: + line("allowed by a standing grant, without a tap") + case p.Via == audit.ViaScheduled: + line("allowed by a scheduled grant, without a tap") + default: + line(`allowed by a "never ask" rule, without a tap`) + } + } + verified := func(label, value string) { line("✅ " + label + ": " + value) } + verified("machine", p.Client) + if len(p.Secrets) > 0 { + verified("secrets", strings.Join(p.Secrets, ", ")) + } + if p.Key != "" { + verified("key", p.Key) + } + switch { + case p.Kind == audit.KindSSHLogin: + verified("host", p.Hosts[0]+" (host key "+p.HostKey[:len("SHA256:")+4]+"…)") + case len(p.Hosts) > 0: + verified("hosts", strings.Join(p.Hosts, ", ")) + } + if p.RemoteUser != "" { + verified("remote user", p.RemoteUser) + } + switch p.Kind { + case audit.KindSSHGrant: + verified("grants end", p.GrantEnds.Format(clockFormat)+" ("+strconv.Itoa(p.WindowMinutes)+" min)") + case audit.KindPreapprove: + verified("uses", strconv.Itoa(p.Uses)) + verified("from", p.From.Format(dateTimeFormat)) + verified("until", p.Until.Format(dateTimeFormat)) + case audit.KindStanding: + for _, w := range p.Windows { + verified("window", w) + } + verified("uses in each window", strconv.Itoa(p.Uses)) + verified("from", p.From.Format(dateTimeFormat)) + verified("until", p.Until.Format(dateTimeFormat)) + line("A Deny of other requests does not end this grant. Use Revoke to end it.") + } + fields := []struct { + label, value string + limit int + }{ + {"as", p.Claim.Vars, c.vars}, + {"command", p.Claim.Command, c.command}, + {"in", p.Claim.Dir, c.dir}, + {"reason", p.Claim.Reason, c.reason}, + } + head := false + for _, f := range fields { + v := claimedValue(f.value, f.limit) + if v == "" { + continue + } + if !head { + line(claimedHead) + head = true + } + line(f.label + ": " + v) + } + return b.String() +} + +// fitBodyWith returns the body of p in at most limit bytes, and, if fits is +// not nil, so that fits says yes (it checks the whole publish body). It cuts the +// claimed fields shorter as needed, never the verified ones: the person +// must see every name they approve. +func fitBodyWith(p approval.Prompt, notice bool, limit int, fits func(string) bool) (string, error) { + for c := fullCaps; c.command >= minCommandCap; c = c.half() { + if s := body(p, notice, c); len(s) <= limit && (fits == nil || fits(s)) { + return s, nil + } + } + return "", errTooLong +} + +// expiryLine is the last line of a message that waits for an answer. +func expiryLine(p approval.Prompt) string { + secs := int64(p.TimeLeft.Round(time.Second) / time.Second) + return "expires " + p.Expires.Format(clockFormat) + " (" + strconv.FormatInt(secs, 10) + " s)" +} + +// buttonLabels returns the labels of the buttons of p, in order, with +// their actions. +func buttonLabels(p approval.Prompt) ([]string, []audit.Answer) { + var labels []string + var actions []audit.Answer + if p.Once { + label := "Approve once" + if p.Kind.IsPreapprove() { + label = "Approve" + } + labels, actions = append(labels, label), append(actions, audit.AnswerApproveOnce) + } + if p.WindowMinutes > 0 { + label := "Approve " + strconv.Itoa(p.WindowMinutes) + " min" + if p.Kind == audit.KindSSHGrant { + label = "Approve" + } + labels, actions = append(labels, label), append(actions, audit.AnswerApproveWindow) + } + return append(labels, "Deny"), append(actions, audit.AnswerDeny) +} + +// buttonsLine shows labels as one line, as DESIGN.md section 7 does. +func buttonsLine(labels []string) string { + parts := make([]string, len(labels)) + for i, l := range labels { + parts[i] = "[ " + l + " ]" + } + return strings.Join(parts, " ") +} + +// endingBody returns the message of p after its request ended: the same +// lines, with the result instead of the expiry. +// +// fits says whether a whole message text may be published; see fitBodyWith. +func endingBody(p approval.Prompt, e approval.Ending, fits func(string) bool) string { + last := "\n" + resultLine(p, e) + var s string + if checkPrompt(p, false) == nil { + s, _ = fitBodyWith(p, false, maxMessageBytes-lastLineRoom, func(b string) bool { return fits(b + last) }) + } + if s == "" { + s = "This request has ended." + } + return s + last +} + +// resultLine says how the request of p ended (DESIGN.md section 7). +func resultLine(p approval.Prompt, e approval.Ending) string { + at := e.At.Format(clockFormat) + if e.GrantEnd != "" { + approved := resultLine(p, approval.Ending{Answer: e.Answer, At: e.Approved}) + return approved + "\n" + grantEndLine(p, e) + } + switch e.Answer { + case audit.AnswerApproveOnce: + switch p.Kind { + case audit.KindStanding: + return "👍 approved at " + at + ", for " + strconv.Itoa(p.Uses) + " use(s) in each window until " + + p.Until.Format(dateTimeFormat) + case audit.KindPreapprove: + return "👍 approved at " + at + ", for " + strconv.Itoa(p.Uses) + " use(s) until " + + p.Until.Format(dateTimeFormat) + } + return "👍 approved once at " + at + case audit.AnswerApproveWindow: + if p.Kind == audit.KindSSHGrant { + return "👍 approved at " + at + ", until " + p.GrantEnds.Format(clockFormat) + } + return "👍 approved for " + strconv.Itoa(e.WindowMinutes) + " min at " + at + case audit.AnswerDeny: + return "⛔ denied at " + at + } + switch e.Cause { + case audit.CauseExpired: + return "⌛ expired — denied" + case audit.CauseCancelled: + return "⛔ cancelled by the client" + case audit.CauseError: + return "⛔ denied: the broker stopped taking answers" + } + return "⛔ denied" +} + +// grantEndLine says how and when the scheduled grant of p ended. +func grantEndLine(p approval.Prompt, e approval.Ending) string { + at := e.At.Format(dateTimeFormat) + left := " with " + strconv.Itoa(e.UsesLeft) + " use(s) left" + if p.Kind == audit.KindStanding { + left = "" + } + switch e.GrantEnd { + case audit.EndUsedUp: + return "✅ all " + strconv.Itoa(p.Uses) + " use(s) used; the grant ended at " + at + case audit.EndEnded: + return "⌛ ended at " + at + left + case audit.EndCancelled: + return "⛔ cancelled by the client at " + at + left + case audit.EndDenied: + return "⛔ ended by a later Deny at " + at + left + case audit.EndRevoked: + return "⛔ revoked at " + at + left + case audit.EndAlarm: + return "🚨 ended by an alarm at " + at + case audit.EndRenewed: + return "🔁 renewed at " + at + "; a new grant replaced this one" + } + return "⛔ dropped: the broker stopped at " + at + left +} + +// cutBytes cuts s to at most limit bytes, at the start of a character, +// with "…" at the end when it cuts. +func cutBytes(s string, limit int) string { + if len(s) <= limit { + return s + } + n := limit - len("…") + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] + "…" +} + +func (c caps) half() caps { + return caps{vars: c.vars / 2, command: c.command / 2, dir: c.dir / 2, reason: c.reason / 2} +}