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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,42 @@ The link:

The audit log records links created, sign-ins started, connections, failures and deletions, never a value, code, token, link or state. A link connects whoever opens it to this keyring: a forwarded link connects the wrong account. Keep links short-lived and send them only to the person they are for.

### Instance mode: one small keyring per person

`keyring serve` (files, `SIGHUP`, dashboard) stays the way to run the owner's keyring. `keyring instance` runs one person's keyring for a supervisor, such as a process manager on a host or, at Cloudflare, the person's Durable Object (#46). No hand-edited files are involved, and the person's keys stay in a separate process from every other person's.

```sh
keyring instance --admin-listen 127.0.0.1:7720 --admin-token-sha256 <hex> [--state /var/lib/keyring/alice.sealed] [--audit /var/log/keyring/alice.log]
```

- The admin API listens on a loopback address and needs `Authorization: Bearer <admin token>` on every call. Only the token's SHA-256 is given to the process.
- Everything the instance knows is one **sealed state**: network settings, services, roles (token hashes only), consent settings and key values. It is encrypted with AES-256-GCM and bound to the instance id. The 32-byte data key comes from the supervisor with `create`, `unlock` or `rotate`, and the keyring never writes it anywhere. With `--state` the sealed state is kept in that file (mode 600). Without it, the state lives only in memory and the supervisor stores it from `GET /state`.
- Every change is validated like the access file, and any new listener must be able to bind. Only then is the state sealed, written and applied; a refused change leaves everything as it was. Each change increases `version` by 1.
- The audit log (`--audit`, or stdout) takes one JSON line per proxied call and per admin change. It never contains a key value, token or data key.
- With no `create` or `unlock`, the instance serves only its admin API.

| Call | Body | Reply |
|---|---|---|
| `POST /create` | `{"id", "data_key", "listen", "allow_sources"}` | 201 `{"version"}`; refused if a state exists |
| `POST /unlock` | `{"data_key", "state"?}` (`state`: base64 sealed state; else the `--state` file) | `{"version"}`; 403 for a wrong key or another instance's state |
| `GET /status` | | settings, services, roles (no token hashes), key **names** |
| `GET /state` | | `{"version", "state"}` (base64 sealed state) |
| `PUT /network` | `{"listen", "allow_sources"}` | moves the proxy |
| `PUT /consent`, `DELETE /consent` | the `consent` block of `rules.json` | consent listeners start or stop |
| `PUT /services/<name>`, `DELETE /services/<name>` | a service, as in the access file | |
| `PUT /roles/<name>` | `{"access", "expires"?}` | `{"version", "token"}`; the token appears only when the role is new |
| `POST /roles/<name>/token` | | `{"token"}`; the old token stops working |
| `DELETE /roles/<name>` | | |
| `PUT /keys/<name>`, `DELETE /keys/<name>` | `{"value"}` | |
| `POST /rotate` | `{"data_key"}` | seals with the new key; the old key opens nothing written after this |
| `DELETE /instance` | | overwrites and removes the state file, forgets the state and key, stops the listeners; the process exits |

After `DELETE /instance`, a copy of the sealed state that the supervisor still holds can be opened only with the data key. The supervisor deletes its copy of the key, and then nothing can open it.

One process per person, not one process for many people: the operating system keeps each person's keys in a separate address space, the proxy code is unchanged, and it matches one container per person at Cloudflare. An idle instance measured on Linux x64 (Go 1.24, one service, one role): about 12 MB resident, 8 threads, and 0 CPU ticks over 30 idle seconds.

Limits of this version: daily counts and open consent links are in memory, so a restart resets them (as with `keyring serve`); #50 tracks keeping them across a container's sleep. The proxy listener still refuses a wildcard address (#51).

### Ed25519 signing (not an API proxy)

For artifacts that need a signature without the key leaving this process — the
Expand Down
68 changes: 68 additions & 0 deletions cmd/keyring/instance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/gitmoot/keyring/internal/instance"
)

// runInstance serves one person's keyring until SIGTERM or until it is
// deleted through its admin API.
func runInstance(adminListen, adminTokenSHA, statePath, auditPath string, stdout, stderr io.Writer) error {
audit, auditName := stdout, "stdout"
if auditPath != "" {
f, err := openAudit(auditPath)
if err != nil {
return err
}
defer f.Close()
audit, auditName = f, auditPath
}
in, err := instance.New(instance.Options{
AdminListen: adminListen, AdminTokenSHA256: adminTokenSHA, StatePath: statePath,
Audit: audit, AuditName: auditName, Log: stderr,
})
if err != nil {
return err
}
listener, err := net.Listen("tcp", adminListen)
if err != nil {
return err
}
srv := &http.Server{
Handler: in.AdminHandler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: time.Minute,
WriteTimeout: time.Minute,
IdleTimeout: 2 * time.Minute,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
served := make(chan error, 1)
go func() { served <- srv.Serve(listener) }()
fmt.Fprintf(stderr, "keyring instance: admin API on %s, locked\n", adminListen)
var serveErr error
select {
case <-ctx.Done():
case <-in.Wiped():
fmt.Fprintln(stderr, "keyring instance: deleted")
case serveErr = <-served:
}
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
in.Close()
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
}
return nil
}
12 changes: 12 additions & 0 deletions cmd/keyring/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import (

const usage = `Usage:
keyring serve --config FILE --store FILE run the keyring
keyring instance --admin-listen ADDR --admin-token-sha256 HEX [--state FILE] [--audit FILE]
run one person's keyring, configured only through its admin
API, its state sealed with a data key from the supervisor
keyring check --config FILE [--store FILE] check the rules and access file (and which keys are missing)
keyring migrate --config FILE --access FILE move services and roles from an old rules file into an access file
keyring enable-dashboard --config FILE [--admin-listen 127.0.0.1:7702] [--https-host NAME --device IP ...]
Expand Down Expand Up @@ -82,6 +85,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
tokensDir := fs.String("tokens", "", "relay: directory of <role>.token files (mode 700)")
upgradeTo := fs.String("version", "", "upgrade: install this release tag instead of the latest")
secretFile := fs.String("file", "", "set: import a private file verbatim, or /dev/stdin for a protected pipe (up to 1 MiB)")
adminTokenSHA := fs.String("admin-token-sha256", "", "instance: hex SHA-256 of the supervisor's admin token")
statePath := fs.String("state", "", "instance: keep the sealed state in this file (default: memory only)")
auditPath := fs.String("audit", "", "instance: audit log file (default: stdout)")
if err := fs.Parse(rest); err != nil {
return 2
}
Expand All @@ -100,6 +106,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
var err error
switch cmd {
case "instance":
if fs.NArg() != 0 || *adminTokenSHA == "" {
fmt.Fprintln(stderr, "keyring instance: pass --admin-listen and --admin-token-sha256")
return 2
}
err = runInstance(*adminListen, *adminTokenSHA, *statePath, *auditPath, stdout, stderr)
case "serve":
if !need("config", "store") || fs.NArg() != 0 {
return 2
Expand Down
Loading
Loading