Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
97e9126
chore: start standing grants (#110)
groupsky Oct 29, 2026
7bde95f
docs: standing grants and the grant core (#110)
groupsky Oct 29, 2026
b6ccff0
feat(config): the standing rule flag and the [standing] section (#110)
groupsky Oct 29, 2026
4f2c170
feat(approval): grant kinds and windows for standing grants (#110)
groupsky Oct 29, 2026
0a81080
feat(grantkey): the standing grant token (#110)
groupsky Oct 29, 2026
817b8f7
feat: standing grant requests in nodkey preapprove (#110)
groupsky Oct 29, 2026
6cbb427
feat: nodkey run --grant-token (#110)
groupsky Oct 29, 2026
cf4b0f8
feat(broker): make standing grants (#110)
groupsky Oct 29, 2026
23b2c83
feat(audit): grant_alarm and new_token_hash entries (#110)
groupsky Oct 29, 2026
c930c57
feat(broker): use and rotate standing grant tokens, with alarms (#110)
groupsky Oct 29, 2026
ae944af
test: alarms through Telegram, failed audit write, cancel after rotat…
groupsky Oct 29, 2026
136a570
test: no gap in the windows of the alarm test (#110)
groupsky Oct 29, 2026
4c2254e
fix: let a client cancel its standing grants (#110)
groupsky Oct 29, 2026
690ccf8
fix: read the grant token file without following links (#110)
groupsky Oct 29, 2026
3851b8d
fix: end a grant whose token another client sends; mark outside_windo…
groupsky Oct 29, 2026
ba51f50
docs: keep the grant token file in a private folder (#110)
groupsky Oct 29, 2026
272ffe4
chore: drop unused nolint (#110)
groupsky Oct 29, 2026
464e786
fix: name the owner in a foreign token alarm; do not wait on a pipe (…
groupsky Oct 29, 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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Planned. Each v1 issue adds its part.
- `internal/version/` - versions of nodkey: the rule of a version that
the other side sends, release versions, and how two compare (DESIGN.md
section 10).
- `internal/grantkey/` - the token of a standing grant: make, parse,
hash, and hide it in every format (DESIGN.md section 8).
- `internal/release/` - the newest release of nodkey on GitHub: the
lookup for `nodkey version`, and the broker's check for a newer
release, with its notice (DESIGN.md section 15). Its tests use a fake
Expand Down
206 changes: 199 additions & 7 deletions DESIGN.md

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,47 @@ use up to N uses of what it names. After a use, the secret stays on the
agent machine (section 5, item 1). Choose short spans, few uses and
narrow secrets, as for a "never ask" rule.

**T36. A standing grant is used by something else on the same client.**
A standing grant (DESIGN.md section 8) lets a client use named secrets in
daily or weekly windows for weeks, without a tap. The broker knows the
client only by its TLS key, so the grant also has a token that changes at
each use. The token is in a file on the client.
- **The honest limit.** The token finds a theft; it does not stop it. The
same user can read the token file, and can rewrite it. A thief who
copies the token and uses it first gets the secrets once. The real job
then comes with the old token, and the broker ends the grant and sends
an alarm. A thief who reads the file and writes back the new token each
time can use the grant like the job. Then the one-use-per-window rule
(`over_uses`) and the missed-window alarm are what catch it.
- The broker keeps only the SHA-256 hash of each token. A token works only
with the TLS key of its client, so a copy on another machine gets an
alarm and no secret.
- Old token, or more uses than allowed: the request is denied, the grant
ends and an alarm comes. A retry after a lost answer looks the same, so
the client saves the new token before it uses the secrets and never
retries by itself. A new grant is one tap, so there is no pause state.
- A valid token outside the windows is denied with an alarm, and the grant
stays; this holds also before the start of the grant, and the day's
alarm counts only if it was sent. The current or old token of another
client's grant has left its machine: the request is denied and that
grant ends, with an alarm that names it. An unknown token is denied with
a rate-limited alarm; it may be a guess. Alarms have sound, count toward
the 20 notices in all, and one grant sends at most one in a window.
- Run the job as its own system user, so an agent that runs as another
user cannot read the token file. The client refuses a token file that
others can read or write, or that it does not own. Root can still read
it.
- A standing grant is wider than a scheduled grant, so it has its own rule
flag (`standing = true`), its own limits (`[standing]`), and a weekly
list of all standing grants on the phone.
- A Deny of another request does not end a standing grant, so a Deny
cannot be used to break the job. The person ends it with **Revoke**.
- Standing grants live in memory and are dropped at a broker stop. The
stop notice lists them (T35).

Risk that remains: the same as T35, for weeks. Choose few uses, short
windows, narrow secrets and a near end date.

## 7. Assumptions

- The broker host is patched, has no agent on it, and only the owner has
Expand Down
93 changes: 93 additions & 0 deletions cmd/nodkey/broker_standing_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"net/http"
"slices"
"strings"
"syscall"
"testing"
"time"

"github.com/code6x/nodkey/internal/api"
"github.com/code6x/nodkey/internal/grantkey"
)

// TestBrokerStandingAlarmThroughTelegram: the alarm of an old token reaches
// the fake Telegram as a message with no buttons, the request is denied,
// and the grant ends: even the new token is refused afterwards.
func TestBrokerStandingAlarmThroughTelegram(t *testing.T) {
t.Parallel()
e := startListening(t, brokerOptions{standing: true})
e.stderr.wait(t, lineListening)
c := e.httpClient()

// Three windows that cover the whole day, in the broker's local time,
// but the last minute: To is at most 23:59 and not part of the window.
if now := time.Now(); now.Hour() == 23 && now.Minute() >= 58 {
t.Skip("too close to midnight, outside the windows")
}
grant := api.PreapproveRequest{
Secrets: []string{"github-token"}, Uses: 1, Reason: "job nightly-backup",
From: time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
Until: time.Now().AddDate(0, 0, 30).UTC().Format(time.RFC3339),
Windows: []api.Window{
{Kind: api.WindowDaily, From: "00:00", To: "08:00"},
{Kind: api.WindowDaily, From: "08:00", To: "16:00"},
{Kind: api.WindowDaily, From: "16:00", To: "23:59"},
},
}
a := e.approve(c, api.PathPreapprove, grant)
mustStatus(t, a, http.StatusOK)
made, err := api.ReadAnswer[api.PreapproveAnswer](strings.NewReader(string(a.body)))
if err != nil {
t.Fatal(err)
}
tok0 := made.GrantToken
if _, err := grantkey.Parse(tok0); err != nil {
t.Fatalf("the token: %v", err)
}

use := func(token string) brokerAnswer {
return e.postWithToken(c, api.PathSecrets, api.SecretsRequest{
Secrets: []api.SecretRef{{Var: "GH_TOKEN", Name: "github-token"}}, Reason: "job nightly-backup",
}, token)
}
// A good use: no tap, a new token.
first := use(tok0)
mustStatus(t, first, http.StatusOK)
rotated, err := api.ReadAnswer[api.SecretsAnswer](strings.NewReader(string(first.body)))
if err != nil || rotated.GrantToken == "" || rotated.GrantToken == tok0 {
t.Fatalf("no new token in the answer: %v", err)
}
if e.tg.alertCount("Standing grant alarm") != 0 {
t.Fatal("an alarm for a good use")
}
asked := e.tg.asked()

// The old token again: denied, one alarm with the fixed text.
mustStatus(t, use(tok0), http.StatusForbidden)
body := e.tg.waitAlert("Standing grant alarm")
if !strings.Contains(body, "an old token was used again") || !strings.Contains(body, "github-token") {
t.Errorf("the alarm: %s", body)
}
// The grant is over: the new token is refused, and nobody is asked.
mustStatus(t, use(rotated.GrantToken), http.StatusForbidden)
if e.tg.asked() != asked {
t.Error("the person was asked for a request with a token")
}

e.signal(syscall.SIGINT)
e.mustExit(exitOK)
texts := e.tg.bodies() + e.stderr.text() + strings.Join(eventNames(e.auditEntries()), " ")
for _, tok := range []string{tok0, rotated.GrantToken} {
if strings.Contains(texts, tok) {
t.Error("a grant token is in the calls to Telegram, the journal or the audit log")
}
}
got := eventNames(e.auditEntries())
for _, want := range []string{"grant_alarm:old_token", "grant_ended:alarm"} {
if !slices.Contains(got, want) {
t.Errorf("no %s entry in %q", want, got)
}
}
}
14 changes: 14 additions & 0 deletions cmd/nodkey/brokerenv_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ type brokerOptions struct {
newestRelease string
// updatesOff adds [updates] check = false to the config.
updatesOff bool
// standing adds [standing] and standing = true for github-token.
standing bool
}

// brokerEnv is nodkey broker in a child process, with its files and the
Expand Down Expand Up @@ -494,6 +496,9 @@ ask = "always"
hosts = ["db-1"]
`
}
if opts.standing {
cfg = strings.Replace(cfg, "[secret.github-token]\n", "[secret.github-token]\nstanding = true\n", 1) + "\n[standing]\n"
}
if opts.updatesOff {
cfg += "\n[updates]\ncheck = false\n"
}
Expand Down Expand Up @@ -770,6 +775,12 @@ type brokerAnswer struct {
// post sends body to path as JSON, and returns the answer. It does not
// fail the test, so it may run in its own goroutine.
func (e *brokerEnv) post(c *http.Client, path string, body any) brokerAnswer {
return e.postWithToken(c, path, body, "")
}

// postWithToken is post with the header of a standing grant token, if
// token is not empty.
func (e *brokerEnv) postWithToken(c *http.Client, path string, body any, token string) brokerAnswer {
data, err := json.Marshal(body)
if err != nil {
return brokerAnswer{err: err}
Expand All @@ -781,6 +792,9 @@ func (e *brokerEnv) post(c *http.Client, path string, body any) brokerAnswer {
return brokerAnswer{err: err}
}
req.Header.Set("Content-Type", api.ContentType)
if token != "" {
req.Header.Set(api.GrantTokenHeader, token)
}
resp, err := c.Do(req)
if err != nil {
return brokerAnswer{err: err}
Expand Down
4 changes: 4 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,10 @@ Things to know:
- Anything on that machine can use the grant inside its time, not only
the job. Keep the span short and the uses few. If something else uses
the grant up, the real job fails, and that shows it.
- If you use a token file (`--grant-token`), keep it in a private folder,
such as the home folder of the job user. Do not put it in a shared
folder like `/tmp`: another user there could delete the file or block
its update.
- For a key, use `--key`, `--user` and `--host` as for `nodkey ssh-grant`.
**Each SSH connection takes one use.** A tool that opens many
connections, such as Ansible, needs more uses, or `ControlPersist` in
Expand Down
7 changes: 7 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const ShortCodeHeader = "Nodkey-Short-Code"
// decides nothing with it. Read it with Version.
const VersionHeader = "Nodkey-Version"

// GrantTokenHeader holds the token of a standing grant (DESIGN.md
// sections 8 and 10). nodkey run sends it with the request for secrets. The
// value is a secret: never print or log it.
const GrantTokenHeader = "Nodkey-Grant-Token" //nolint:gosec // G101: a header name, not a credential.

// Version returns the version in the header VersionHeader of a request or
// an answer. It returns "" when there is no such header, more than one,
// or a value that version.Valid refuses. Such a value counts as no
Expand Down Expand Up @@ -123,6 +128,8 @@ const (
// MaxTime is the most bytes of a time in a PreapproveRequest. Valid
// times are shorter.
MaxTime = 64
// MaxWindows is the most windows of a standing grant.
MaxWindows = 7
// MaxKeys is the most keys that one client may use, so that a
// KeysAnswer stays small. The broker refuses to start with more.
MaxKeys = 256
Expand Down
130 changes: 129 additions & 1 deletion internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ var allTypes = []any{
SecretsRequest{}, SecretRef{}, SecretsAnswer{}, SecretValue{},
SignRequest{}, SignAnswer{},
GrantRequest{}, GrantAnswer{},
PreapproveRequest{}, PreapproveAnswer{}, PreapproveCancelRequest{}, PreapproveCancelAnswer{},
PreapproveRequest{}, Window{}, PreapproveAnswer{}, PreapproveCancelRequest{}, PreapproveCancelAnswer{},
ErrorAnswer{},
}

Expand Down Expand Up @@ -96,7 +96,15 @@ func fullValues() []any {
Secrets: []string{"backup-token"}, Key: "k", User: "u", Hosts: []string{"web-1"},
From: "2026-10-28T03:00", Until: "2026-10-28T04:00+02:00", Uses: 2, Reason: "r",
},
&PreapproveRequest{
Secrets: []string{"backup-token"}, Hosts: []string{}, From: "2026-10-28T03:00", Until: "2026-12-28T04:00+02:00", Uses: 1,
Windows: []Window{{Kind: "daily", Days: []string{}, From: "03:00", To: "05:00"},
{Kind: "weekly", Days: []string{"mon", "thu"}, From: "00:00", To: "23:59"}},
},
&Window{Kind: "weekly", Days: []string{"sun"}, From: "01:05", To: "02:10"},
&PreapproveAnswer{ID: "AAAAAAAAAAAAAAAAAAAAAA", From: "2026-10-28T03:00:00+02:00", Until: "2026-10-28T04:00:00+02:00"},
&PreapproveAnswer{ID: "AAAAAAAAAAAAAAAAAAAAAA", From: "2026-10-28T03:00:00+02:00", Until: "2026-10-28T04:00:00+02:00",
GrantToken: "not-a-real-token"},
&PreapproveCancelRequest{ID: "AAAAAAAAAAAAAAAAAAAAAA"},
&PreapproveCancelAnswer{ID: "AAAAAAAAAAAAAAAAAAAAAA"},
&ErrorAnswer{Error: "Forbidden"},
Expand Down Expand Up @@ -776,3 +784,123 @@ func TestParseTime(t *testing.T) {
}
}
}

// TestPreapproveWindows: the windows of a standing grant are optional,
// capped, and checked strictly; an old request has no field for them.
func TestPreapproveWindows(t *testing.T) {
good := Window{Kind: "daily", Days: []string{}, From: "03:00", To: "05:00"}
base := func(ws ...Window) *PreapproveRequest {
return &PreapproveRequest{Secrets: []string{"s"}, Hosts: []string{}, From: "a", Until: "b", Uses: 1, Windows: ws}
}
data, err := json.Marshal(base())
if err != nil || strings.Contains(string(data), "windows") {
t.Errorf("a request without windows is %s, %v; want no windows field", data, err)
}
if err := base(slices.Repeat([]Window{good}, MaxWindows)...).Check(); err != nil {
t.Errorf("%d windows: %v", MaxWindows, err)
}
for _, tt := range []struct {
name string
w []Window
}{
{"too many", slices.Repeat([]Window{good}, MaxWindows+1)},
{"kind", []Window{{Kind: "monthly", From: "03:00", To: "05:00"}}},
{"empty kind", []Window{{From: "03:00", To: "05:00"}}},
{"long kind", []Window{{Kind: strings.Repeat("a", MaxName+1), From: "03:00", To: "05:00"}}},
{"daily with days", []Window{{Kind: "daily", Days: []string{"mon"}, From: "03:00", To: "05:00"}}},
{"weekly without days", []Window{{Kind: "weekly", From: "03:00", To: "05:00"}}},
{"weekly day twice", []Window{{Kind: "weekly", Days: []string{"mon", "mon"}, From: "03:00", To: "05:00"}}},
{"weekly bad day", []Window{{Kind: "weekly", Days: []string{"Mon"}, From: "03:00", To: "05:00"}}},
{"weekly 8 days", []Window{{Kind: "weekly", Days: slices.Repeat([]string{"mon"}, 8), From: "03:00", To: "05:00"}}},
{"to before from", []Window{{Kind: "daily", From: "05:00", To: "03:00"}}},
{"empty span", []Window{{Kind: "daily", From: "03:00", To: "03:00"}}},
{"hour 24", []Window{{Kind: "daily", From: "03:00", To: "24:00"}}},
{"minute 60", []Window{{Kind: "daily", From: "03:60", To: "04:00"}}},
{"no zero pad", []Window{{Kind: "daily", From: "3:00", To: "05:00"}}},
{"seconds", []Window{{Kind: "daily", From: "03:00:00", To: "05:00"}}},
{"letters", []Window{{Kind: "daily", From: "ab:cd", To: "05:00"}}},
{"long time", []Window{{Kind: "daily", From: strings.Repeat("1", MaxTime+1), To: "05:00"}}},
} {
r := base(tt.w...)
if r.Check() == nil {
t.Errorf("%s: Check passes", tt.name)
}
data, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
if _, err := decodeInto(&PreapproveRequest{}, data); err == nil {
t.Errorf("%s: the request is decoded", tt.name)
}
}
pre := `{"secrets":[],"key":"","user":"","hosts":[],"from":"","until":"","uses":1,"reason":""`
win := `{"kind":"daily","days":[],"from":"03:00","to":"05:00"}`
if _, err := decodeInto(&PreapproveRequest{}, []byte(pre+`,"windows":[`+win+`]}`)); err != nil {
t.Errorf("a valid request: %v", err)
}
if _, err := decodeInto(&PreapproveRequest{}, []byte(pre+`}`)); err != nil {
t.Errorf("a request without windows: %v", err)
}
for _, bad := range []string{
pre + `,"windows":[]}`,
pre + `,"windows":null}`,
pre + `,"windows":[null]}`,
pre + `,"windows":{}}`,
pre + `,"windows":[` + win + `],"windows":[` + win + `]}`,
pre + `,"windows":[{"kind":"daily","days":[],"from":"03:00","to":"05:00","x":1}]}`,
pre + `,"windows":[{"kind":"daily","days":[],"from":"03:00"}]}`,
pre + `,"windows":[{"kind":"daily","days":null,"from":"03:00","to":"05:00"}]}`,
pre + `,"windows":[{"Kind":"daily","days":[],"from":"03:00","to":"05:00"}]}`,
pre + `,"windows":[{"kind":"weekly","days":["mon",null],"from":"03:00","to":"05:00"}]}`,
} {
if _, err := decodeInto(&PreapproveRequest{}, []byte(bad)); err == nil {
t.Errorf("%s: the request was decoded", bad)
}
}
}

// TestPreapproveAnswerToken: grant_token is optional, a string, capped,
// and nothing else is allowed.
func TestPreapproveAnswerToken(t *testing.T) {
pre := `{"id":"AAAAAAAAAAAAAAAAAAAAAA","from":"a","until":"b"`
for _, ok := range []string{pre + `}`, pre + `,"grant_token":"x"}`} {
if _, err := decodeInto(&PreapproveAnswer{}, []byte(ok)); err != nil {
t.Errorf("%s: %v", ok, err)
}
}
for _, bad := range []string{
pre + `,"grant_token":null}`,
pre + `,"grant_token":1}`,
pre + `,"grant_token":"` + strings.Repeat("a", MaxGrantToken+1) + `"}`,
pre + `,"grant_token":"x","grant_token":"y"}`,
pre + `,"token":"x"}`,
`{"id":"AAAAAAAAAAAAAAAAAAAAAA","from":"a","grant_token":"x"}`,
} {
if _, err := decodeInto(&PreapproveAnswer{}, []byte(bad)); err == nil {
t.Errorf("%s: the answer was decoded", bad)
}
}
}

func TestGrantTokenHeader(t *testing.T) {
if GrantTokenHeader != "Nodkey-Grant-Token" { //nolint:gosec // G101: a header name, not a credential.
t.Errorf("GrantTokenHeader = %q", GrantTokenHeader)
}
}

// TestPreapproveAnswerHidesToken: no way of printing or logging the answer
// shows the token.
func TestPreapproveAnswerHidesToken(t *testing.T) {
tok := strings.Repeat("q", 12) + "-made-up"
a := PreapproveAnswer{ID: "AAAAAAAAAAAAAAAAAAAAAA", From: "a", Until: "b", GrantToken: tok}
var buf strings.Builder
slog.New(slog.NewTextHandler(&buf, nil)).Info("x", "answer", a)
slog.New(slog.NewJSONHandler(&buf, nil)).Info("x", "answer", &a)
for _, out := range []string{
fmt.Sprintf("%v %+v %#v %s %q %x %d", a, a, a, a, a, a, a), fmt.Sprintf("%v %+v %#v", &a, &a, &a), buf.String(),
} {
if strings.Contains(out, tok) {
t.Error("an output holds the token")
}
}
}
Loading
Loading