Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
02ed04c
chore: start ntfy approver (#24)
groupsky Oct 31, 2026
470e7ff
feat(ntfy): an ntfy approver (Ask, End, Notify, Run)
groupsky Oct 31, 2026
8d7c544
test(ntfy): a fake ntfy server and the deny paths of the approver
groupsky Oct 31, 2026
6a97687
feat: [ntfy] config section, one approval channel per broker
groupsky Oct 31, 2026
38a2ccf
feat: the core takes answers without a user ID with [ntfy]
groupsky Oct 31, 2026
d690b2c
fix(ntfy): one separate value for each button
groupsky Oct 31, 2026
60d2245
test(ntfy): buttons have different values
groupsky Oct 31, 2026
4a08396
fix(ntfy): bound skipped-line logs and the reconnect rate
groupsky Oct 31, 2026
ad02280
fix(ntfy): keep the whole publish body within the ntfy limit
groupsky Oct 31, 2026
19b50f8
chore(ntfy): drop the unused fitBody
groupsky Oct 31, 2026
6b6bdb5
feat: nodkey broker uses the ntfy approver when the config has [ntfy]
groupsky Oct 31, 2026
24e0acc
test: guard the fake ntfy token with its mutex
groupsky Oct 31, 2026
45e80b4
fix(ntfy): the broker's notices go out with a low priority
groupsky Oct 31, 2026
7516e0b
docs: DESIGN.md for the ntfy channel
groupsky Oct 31, 2026
fc6fd16
fix(ntfy): refuse [standing] with [ntfy]; the unit loads the ntfy token
groupsky Oct 31, 2026
25ccb59
docs: THREAT-MODEL.md for the ntfy channel
groupsky Oct 31, 2026
950735b
docs: setup guide for ntfy
groupsky Oct 31, 2026
c8b7400
docs: manual test for ntfy
groupsky Oct 31, 2026
ab6ca3f
fix(ntfy): result lines do not say "by you"; unit exit codes name ntfy
groupsky Oct 31, 2026
26090ec
fix(ntfy): budget the lost-subscription warning; refuse an empty quer…
groupsky Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
170 changes: 154 additions & 16 deletions DESIGN.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
25 changes: 14 additions & 11 deletions cmd/nodkey/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
57 changes: 57 additions & 0 deletions cmd/nodkey/brokerchannel.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 14 additions & 1 deletion cmd/nodkey/brokerenv_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"} {
Expand Down
Loading
Loading