diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go index 3f80257..2544ab9 100644 --- a/cmd/mesh-control/licence.go +++ b/cmd/mesh-control/licence.go @@ -18,7 +18,7 @@ import ( // them too, because the whole point is saying which one a given consumer uses. func licenceCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("licence add|list|use|release|key|forget") + return errors.New("licence add|list|use|release|key|manager|refresh|forget") } switch args[0] { case "add": @@ -31,10 +31,15 @@ func licenceCommand(ctx context.Context, args []string) error { return licenceUse(ctx, args[1:], false) case "key": return licenceKey(ctx, args[1:]) + case "manager": + return licenceManager(ctx, args[1:]) + case "refresh": + return licenceRefresh(ctx, args[1:]) case "forget": return licenceForget(ctx, args[1:]) } - return fmt.Errorf("licence %q; it is add, list, use, release, key or forget", args[0]) + return fmt.Errorf( + "licence %q; it is add, list, use, release, key, manager, refresh or forget", args[0]) } func licenceAdd(ctx context.Context, args []string) error { @@ -224,6 +229,66 @@ func licenceKey(ctx context.Context, args []string) error { return nil } +// licenceManager names the one node that holds a refreshable-grant licence's refresh token readably +// and refreshes it centrally (novox/hq ADR 0050). +// +// **Only a refreshable-grant licence has one.** A static-key licence has no refresh token to hold, so +// naming a manager for it is refused where the mistake is made rather than kept as a field that means +// nothing — the absent manager is part of what keeps a static key from ever holding a value readably +// at rest. +func licenceManager(ctx context.Context, args []string) error { + if len(args) != 2 { + return errors.New("licence manager ") + } + name, node := args[0], args[1] + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + if err := held.SetManager(ctx, name, node); err != nil { + return err + } + fmt.Printf("%s holds and refreshes %s.\n"+ + " Its refresh token is kept encrypted at rest, readable by %s alone — no other node, and "+ + "not this database on its own.\n", node, name, node) + return nil +} + +// licenceRefresh mints a new access token for a refreshable-grant licence and seals it to every +// holder (novox/hq ADR 0050). The refresh token stays with the manager and is never delivered. +// +// The vendor's actual refresh is a plug-in this build does not ship (Phase C), so here this reports +// that plainly rather than pretending to have refreshed. +func licenceRefresh(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("licence refresh ") + } + name := args[0] + + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + held, err := open.Licences(ctx) + if err != nil { + return err + } + inv := open.inventory + + sealed, err := held.Refresh(ctx, name, func(node string) (string, error) { + return inv.SealingKeyOf(ctx, node) + }) + if err != nil { + return err + } + fmt.Printf("refreshed %s: a new access token sealed to %d holder(s), and the refresh token left "+ + "with its manager.\n run `push` to deliver it\n", name, sealed) + return nil +} + func licenceForget(ctx context.Context, args []string) error { if len(args) != 1 { return errors.New("licence forget ") diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 613f709..d3c4d49 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -151,6 +151,8 @@ func usage() { builds [] what has been built lately, and what came of it builder issue a broker account for a build machine, scoped to build work licence add|list|use|key model access, under the name a person calls it + licence manager the node that holds a refreshable licence's refresh token + licence refresh mint a new access token and seal it to every holder rotate [--consumer ] a new credential for every holder, both ends at once pin which node this one gets a provision from unpin put that question back diff --git a/internal/licences/adapters/adapters.go b/internal/licences/adapters/adapters.go index 21dac65..294d891 100644 --- a/internal/licences/adapters/adapters.go +++ b/internal/licences/adapters/adapters.go @@ -5,13 +5,15 @@ // vendor answers, and the lifecycle that vendor's credential needs, lives here — exactly as // `public-dns` is one neutral interface answered by registrar-scoped providers (ADR 0044). // -// **Phase A ships only the `static-key` shape.** A static-key vendor implements almost nothing: the -// credential is an operator-supplied value, sealed to each holder by the generic anonymous box -// (ADR 0024) and delivered unchanged. The refreshable-grant machinery — central rotation, an -// identity guard, a usage reading, refresh-token-stripped delivery — is Phase B, and its verbs are -// named here as optional capabilities (Refresher, Identifier, UsageReader) so the seam exists -// before the code does. The abstraction earns its keep by making the common vendor small, not the -// rare one clever (ADR 0050). +// **Two shapes.** A `static-key` vendor implements almost nothing: the credential is an +// operator-supplied value, sealed to each holder by the generic anonymous box (ADR 0024) and +// delivered unchanged. A `refreshable-grant` vendor carries the bounded carve-out — a manager node +// holds the refresh token encrypted at rest, access tokens are still sealed per holder, and the +// refresh token is never in a holder's delivery. This package holds the generic half of both. The +// vendor-specific half of a refresh — the actual OAuth call against a vendor's endpoint — is a +// VendorRefresher plugged in from outside (novox/hq ADR 0050, Phase C); this build ships none, and +// a refresh on a vendor with nothing plugged in is refused in as many words rather than pretended. +// The abstraction earns its keep by making the common vendor small, not the rare one clever. package adapters import ( @@ -19,6 +21,7 @@ import ( "fmt" "sort" "strings" + "sync" "github.com/novox/mesh-control/internal/secrets" ) @@ -55,11 +58,72 @@ type Adapter interface { Deliver(sealed string) string } -// Refresher is implemented only by a refreshable-grant adapter (ADR 0050, Phase B): the -// lease / rotate / publish machinery a manager node runs. A static-key adapter does not implement -// it, and a caller finds its absence by a type assertion. +// RefreshInput is what producing a new access token needs, and all a refresh is given. +// +// It carries the refresh token **only as its at-rest envelope** — the caller (the control plane) +// never holds the refresh token in the clear, because it cannot open the envelope. Opening it, and +// the vendor call that follows, happen where the manager node's private key is (ADR 0050, Phase C). +type RefreshInput struct { + Licence string + // Manager is the node that holds the refresh token readably — the one place the envelope opens. + Manager string + // AtRest is the refresh token encrypted at rest under the manager's key. Opaque to the control + // plane; meaningful only to the manager node that produced it. + AtRest secrets.AtRest +} + +// RefreshResult is what a refresh produced: a new access token to seal per holder, and — only if the +// vendor rotated it — the refresh token re-encrypted at rest, ready to replace the stored envelope. +type RefreshResult struct { + // AccessToken is the new access token, in the clear. The mesh seals it per holder and discards + // it, exactly as it does an accepted key. It is never the refresh token. + AccessToken string + // NewAtRest is the refresh token re-sealed at rest, present only when the vendor rotated the + // refresh token too. Nil leaves the stored envelope untouched. Already encrypted, so the control + // plane stores it without ever seeing the refresh token in the clear. + NewAtRest *secrets.AtRest +} + +// Refresher is implemented only by a refreshable-grant adapter (ADR 0050): the vendor-neutral half +// of the lease / rotate / publish machinery. A static-key adapter does not implement it, and a +// caller finds its absence by a type assertion — which is exactly what gates the carve-out to +// refreshable-grant vendors. type Refresher interface { - Refresh(ctx context.Context, licence string) error + Refresh(ctx context.Context, in RefreshInput) (RefreshResult, error) +} + +// VendorRefresher is the vendor-specific half, plugged in from outside (ADR 0050, Phase C): given a +// refresh, it opens the at-rest envelope with the manager node's key, calls the vendor's endpoint, +// and returns the new access token (and a re-sealed refresh token if the vendor rotated it). It is +// the one component that reads a refresh token in the clear, and in production it runs where the +// manager node's private key is. This build registers none; Anthropic's OAuth refresh is Phase C. +type VendorRefresher interface { + Refresh(ctx context.Context, in RefreshInput) (RefreshResult, error) +} + +// refreshers is what has been plugged in, keyed by vendor. Empty in this build. +var refreshers struct { + sync.RWMutex + byVendor map[string]VendorRefresher +} + +// RegisterRefresher plugs a vendor's real refresh in (ADR 0050, Phase C), or a fake in a test. The +// generic refreshable-grant adapter for that vendor then delegates to it. Registering nothing leaves +// the seam empty, and a refresh on that vendor is refused with a message that names Phase C rather +// than failing obscurely. +func RegisterRefresher(vendor string, r VendorRefresher) { + refreshers.Lock() + defer refreshers.Unlock() + if refreshers.byVendor == nil { + refreshers.byVendor = map[string]VendorRefresher{} + } + refreshers.byVendor[vendor] = r +} + +func refresherFor(vendor string) VendorRefresher { + refreshers.RLock() + defer refreshers.RUnlock() + return refreshers.byVendor[vendor] } // Identifier is the mis-binding guard (ADR 0050, Phase B): a vendor whose credential carries an @@ -89,15 +153,16 @@ type UsageRow struct { // registry maps a vendor name to the shape its adapter has. // -// The single place a vendor is taught to the mesh. For Phase A the only entry is anthropic as a -// static-key vendor, which is what the lab bed exercises. +// The single place a vendor is taught to the mesh. anthropic's real credential is a subscription +// OAuth grant, so it is the first `refreshable-grant` vendor; `anthropic-api-key` is the same +// company's plain API keys, the early second `static-key` case ADR 0050 names — it exercises the +// whole path with the carve-out switched off. Static-key vendors are added here as one line each. // -// TODO(Phase B, ADR 0050): anthropic's real credential is a subscription OAuth grant, so its entry -// becomes RefreshableGrant and a refreshable-grant adapter is registered for it; the static-key -// path for the same vendor's plain API keys moves to a separate `anthropic-api-key` vendor, the -// early second static-key case ADR 0050 names. Static-key vendors are added here as one line each. +// TODO(Phase C, ADR 0050): register anthropic's real VendorRefresher — the OAuth refresh against the +// vendor's endpoint — with RegisterRefresher. Until then a refresh on it is refused, naming Phase C. var registry = map[string]Shape{ - "anthropic": StaticKey, + "anthropic": RefreshableGrant, + "anthropic-api-key": StaticKey, } // For returns the adapter for a vendor, refusing an unknown one clearly. @@ -115,12 +180,15 @@ func For(vendor string) (Adapter, error) { switch shape { case StaticKey: return staticKey{vendor: vendor}, nil + case RefreshableGrant: + // The generic refreshable-grant adapter, carrying whatever VendorRefresher has been plugged + // in for this vendor — nil in this build, which a refresh reports rather than hides. + return refreshableGrant{vendor: vendor, refresher: refresherFor(vendor)}, nil default: - // A vendor registered with a shape this build does not implement yet — the refreshable-grant - // seam. Said plainly rather than answered with a static-key adapter that would silently - // mishandle it. + // A shape this build has no adapter for at all. Said plainly rather than answered with an + // adapter that would silently mishandle it. return nil, fmt.Errorf( - "the vendor %q needs a %s adapter, which this build does not ship yet", vendor, shape) + "the vendor %q has shape %q, which this build has no adapter for", vendor, shape) } } @@ -153,3 +221,48 @@ func (s staticKey) Accept(value, consumerKey, providerKey string) (secrets.Seale // unchanged — there is no vendor step between the store and the node, and the mesh never sees the // plaintext. func (s staticKey) Deliver(sealed string) string { return sealed } + +// refreshableGrant is the adapter for a vendor whose credential is an OAuth-style grant a manager +// node refreshes centrally (novox/hq ADR 0050). It is vendor-neutral: the generic half — the shape +// that gates the carve-out, the per-holder seal of an access token, delivery that carries only an +// access token, and the dispatch of a refresh to a plugged-in VendorRefresher — lives here; the +// vendor's actual OAuth call is the injected refresher. +// +// **What makes this the carve-out and not a second static key.** The refresh token never touches +// this adapter and never touches a holder. It lives in the licences context's own at-rest store, +// keyed by licence, encrypted to the manager node (secrets.AtRest). What Accept seals and Deliver +// hands out is the ACCESS token, per holder, exactly as a static key's value is — so "the refresh +// token is stripped on delivery" is structural here: there is nothing in a holder's row to strip, +// because the refresh token was never put there. +type refreshableGrant struct { + vendor string + refresher VendorRefresher +} + +func (r refreshableGrant) Vendor() string { return r.vendor } +func (r refreshableGrant) Shape() Shape { return RefreshableGrant } + +// Accept seals an access token to one holder — the generic anonymous-box seal, the same as a static +// key. The refresh token is not accepted here: it is adopted onto the manager node and kept in the +// at-rest store (ADR 0050, Phase C), never sealed per holder. +func (r refreshableGrant) Accept(value, consumerKey, providerKey string) (secrets.Sealed, error) { + return secrets.Accept(value, consumerKey, providerKey) +} + +// Deliver hands the consumer its sealed ACCESS token, unchanged. There is no refresh token to strip +// because a holder's row never held one — the stripping is in the shape of the store, not a step +// here. +func (r refreshableGrant) Deliver(sealed string) string { return sealed } + +// Refresh is the vendor-neutral half of the lease/rotate/publish machinery: it dispatches to the +// vendor's plugged-in refresher and returns what that produced. The lease, the per-holder reseal and +// the publish are the licences context's (ADR 0050, Phase B); the OAuth call is the vendor's +// (Phase C). With nothing plugged in, it refuses in a way that names why. +func (r refreshableGrant) Refresh(ctx context.Context, in RefreshInput) (RefreshResult, error) { + if r.refresher == nil { + return RefreshResult{}, fmt.Errorf( + "the vendor %q is refreshable-grant but has no refresher plugged in, so its grant "+ + "cannot be refreshed in this build (novox/hq ADR 0050, Phase C)", r.vendor) + } + return r.refresher.Refresh(ctx, in) +} diff --git a/internal/licences/adapters/adapters_test.go b/internal/licences/adapters/adapters_test.go new file mode 100644 index 0000000..25b87d8 --- /dev/null +++ b/internal/licences/adapters/adapters_test.go @@ -0,0 +1,112 @@ +package adapters + +import ( + "context" + "strings" + "testing" + + "github.com/novox/mesh-control/internal/secrets" +) + +func TestTheTwoShapesAreSelectedByVendor(t *testing.T) { + static, err := For("anthropic-api-key") + if err != nil { + t.Fatal(err) + } + if static.Shape() != StaticKey { + t.Fatalf("anthropic-api-key is %q, expected static-key", static.Shape()) + } + + grant, err := For("anthropic") + if err != nil { + t.Fatal(err) + } + if grant.Shape() != RefreshableGrant { + t.Fatalf("anthropic is %q, expected refreshable-grant", grant.Shape()) + } +} + +// The type assertion is what gates the carve-out: a static key is not a Refresher, so it can never +// reach the machinery that holds a token readably. A refreshable grant is one. +func TestOnlyARefreshableGrantIsARefresher(t *testing.T) { + static, _ := For("anthropic-api-key") + if _, ok := static.(Refresher); ok { + t.Fatal("a static-key adapter is a Refresher, so the carve-out is not gated by shape") + } + grant, _ := For("anthropic") + if _, ok := grant.(Refresher); !ok { + t.Fatal("a refreshable-grant adapter is not a Refresher, so it cannot be refreshed") + } +} + +// With nothing plugged in, a refresh is refused in a way that names why — not answered with a +// silent no-op that would look like a refresh that changed nothing. +func TestARefreshWithNoRefresherPluggedInIsRefused(t *testing.T) { + grant, _ := For("anthropic") + r := grant.(Refresher) + _, err := r.Refresh(context.Background(), RefreshInput{Licence: "personal"}) + if err == nil { + t.Fatal("a refresh succeeded with no vendor refresher plugged in") + } + if !strings.Contains(err.Error(), "Phase C") { + t.Fatalf("the refusal does not point at the missing plug-in: %v", err) + } +} + +type fakeVendor struct { + result RefreshResult + got RefreshInput +} + +func (f *fakeVendor) Refresh(_ context.Context, in RefreshInput) (RefreshResult, error) { + f.got = in + return f.result, nil +} + +// A plugged-in refresher is dispatched to, and is handed the at-rest envelope (never a plaintext +// refresh token) plus which node is the manager. +func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) { + fake := &fakeVendor{result: RefreshResult{AccessToken: "at-new"}} + RegisterRefresher("anthropic", fake) + defer RegisterRefresher("anthropic", nil) + + grant, _ := For("anthropic") + r := grant.(Refresher) + in := RefreshInput{ + Licence: "personal", Manager: "workstation", + AtRest: secrets.AtRest{Token: "tok", WrappedKey: "wk", ManagerKey: "mk"}, + } + out, err := r.Refresh(context.Background(), in) + if err != nil { + t.Fatal(err) + } + if out.AccessToken != "at-new" { + t.Fatalf("the dispatched result did not come back: %q", out.AccessToken) + } + if fake.got.Manager != "workstation" || fake.got.AtRest.Token != "tok" { + t.Fatalf("the refresher was handed the wrong input: %+v", fake.got) + } +} + +// A vendor this build has no adapter for is refused, and the refusal lists what it does know so a +// typo and an unsupported vendor are told apart. +func TestAnUnknownVendorIsRefusedWithTheList(t *testing.T) { + _, err := For("acme-models") + if err == nil { + t.Fatal("an unknown vendor returned an adapter") + } + if !strings.Contains(err.Error(), "anthropic") { + t.Fatalf("the refusal does not list the known vendors: %v", err) + } +} + +// Both shapes deliver their stored blob unchanged: a static key has no vendor step, and a +// refreshable grant's holder row holds an access token with no refresh token to strip. +func TestBothShapesDeliverTheStoredBlobUnchanged(t *testing.T) { + for _, vendor := range []string{"anthropic", "anthropic-api-key"} { + a, _ := For(vendor) + if got := a.Deliver("sealed-blob"); got != "sealed-blob" { + t.Fatalf("%s changed the delivered blob to %q", vendor, got) + } + } +} diff --git a/internal/licences/licences.go b/internal/licences/licences.go index 8e8e4a8..bdf59ac 100644 --- a/internal/licences/licences.go +++ b/internal/licences/licences.go @@ -21,6 +21,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/novox/mesh-control/internal/licences/adapters" + "github.com/novox/mesh-control/internal/secrets" "github.com/novox/mesh-control/internal/store" ) @@ -304,6 +305,257 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali return sealed, nil } +// ManagerOf is the node that holds a licence's refresh token readably, empty if none is named. +// +// Empty for every static-key licence, which has nothing to refresh, and for a refreshable-grant one +// before its manager is set (novox/hq ADR 0050). +func (l *Licences) ManagerOf(ctx context.Context, licence string) (string, error) { + var manager *string + err := l.store.Pool().QueryRow(ctx, + `select manager from licence where name = $1`, licence).Scan(&manager) + if errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("this mesh has no licence called %q", licence) + } + if err != nil { + return "", err + } + if manager == nil { + return "", nil + } + return *manager, nil +} + +// SetManager names the one node that holds a licence's refresh token and refreshes it centrally. +// +// **Only a refreshable-grant licence has one.** A static-key licence has no refresh token, so naming +// a manager for it is refused rather than kept — the absent manager is part of what keeps a static +// key from ever growing a value something holds readably at rest (novox/hq ADR 0050). The bound +// "the manager node only" starts here, at the one place a manager is written. +func (l *Licences) SetManager(ctx context.Context, licence, node string) error { + if strings.TrimSpace(node) == "" { + return errors.New("a manager needs a node") + } + vendor, err := l.vendorOf(ctx, licence) + if err != nil { + return err + } + adapter, err := adapters.For(vendor) + if err != nil { + return err + } + if adapter.Shape() != adapters.RefreshableGrant { + return fmt.Errorf( + "%q is a %s licence; only a refreshable-grant licence has a manager, because only it "+ + "has a refresh token to hold", licence, adapter.Shape()) + } + tag, err := l.store.Pool().Exec(ctx, + `update licence set manager = $2 where name = $1`, licence, node) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("this mesh has no licence called %q", licence) + } + return nil +} + +// SetRefreshGrant stores, or replaces, a licence's refresh token as its at-rest envelope. +// +// **The envelope is opaque here.** It was produced by the manager node — the only place the refresh +// token is ever in the clear (novox/hq ADR 0050, Phase C) — and this context keeps it and forwards +// it to a refresh without opening it. The control plane holds no key that could, which is the whole +// point of where the carve-out draws the line. +func (l *Licences) SetRefreshGrant(ctx context.Context, licence string, at secrets.AtRest) error { + if at.Token == "" || at.WrappedKey == "" || at.ManagerKey == "" { + return errors.New("an incomplete refresh-token envelope is not one to keep") + } + _, err := l.store.Pool().Exec(ctx, + `insert into refresh_grant (licence, token, wrapped_key, manager_key) + values ($1, $2, $3, $4) + on conflict (licence) do update set + token = excluded.token, wrapped_key = excluded.wrapped_key, + manager_key = excluded.manager_key, updated_at = now()`, + licence, at.Token, at.WrappedKey, at.ManagerKey) + if err != nil && strings.Contains(err.Error(), "refresh_grant_licence_fkey") { + return fmt.Errorf("this mesh has no licence called %q", licence) + } + return err +} + +// RefreshGrant is a licence's refresh token as its at-rest envelope, and whether one is stored. +func (l *Licences) RefreshGrant(ctx context.Context, licence string) (secrets.AtRest, bool, error) { + var at secrets.AtRest + err := l.store.Pool().QueryRow(ctx, + `select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence). + Scan(&at.Token, &at.WrappedKey, &at.ManagerKey) + if errors.Is(err, pgx.ErrNoRows) { + return secrets.AtRest{}, false, nil + } + if err != nil { + return secrets.AtRest{}, false, err + } + return at, true, nil +} + +// Refresh mints a new access token for a refreshable-grant licence, seals it to every holder, and +// leaves the refresh token where it is — re-encrypted at rest if the vendor rotated it too. +// +// **The lease.** One refresh of a licence at a time, held as a transaction-scoped advisory lock on +// the licence: two refreshes serialise rather than both minting a token and racing to publish. This +// is doc 13's single-actor rotation lease, expressed against the database that is the source of +// truth (novox/hq ADR 0003) rather than reinvented. +// +// **It reuses rotation's reseal-and-publish, not its value source.** doc 13's rotate discards a +// mesh-minted secret and regenerates it; here the new access token comes from the vendor refresh +// instead, and is then sealed per holder (secrets.Seal, exactly as Accept does) and delivered on the +// next push — the same publish path any credential change takes. The refresh token is never sealed +// to a holder, so `KeyFor` cannot deliver it. +// +// **All or nothing.** The reseal, the grant replacement and the lease are one transaction: a refresh +// that cannot finish leaves every holder on the token it had and the stored grant untouched — a +// licence that has not refreshed, which is far better than one half refreshed (doc 13). +// +// The vendor refresh itself is the injected VendorRefresher (novox/hq ADR 0050, Phase C); with none +// plugged in, the adapter refuses here and nothing is changed. +func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys) (int, error) { + tx, err := l.store.Pool().Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + // The lease. Released when the transaction ends, either way. + if _, err := tx.Exec(ctx, + `select pg_advisory_xact_lock(hashtext($1)::bigint)`, licence); err != nil { + return 0, fmt.Errorf("cannot take the refresh lease on %q: %w", licence, err) + } + + var vendor string + var manager *string + err = tx.QueryRow(ctx, + `select vendor, manager from licence where name = $1`, licence).Scan(&vendor, &manager) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("this mesh has no licence called %q", licence) + } + if err != nil { + return 0, err + } + + adapter, err := adapters.For(vendor) + if err != nil { + return 0, err + } + refresher, ok := adapter.(adapters.Refresher) + if !ok { + // The type assertion is what gates the carve-out to refreshable-grant vendors: a static key + // is not a Refresher, so it can never reach the machinery that holds a token readably. + return 0, fmt.Errorf( + "%q is a %s licence and cannot be refreshed; only a refreshable-grant licence has a "+ + "refresh token", licence, adapter.Shape()) + } + if manager == nil || *manager == "" { + return 0, fmt.Errorf( + "%q has no manager named, so there is no node to refresh it. Name one:\n"+ + " licence manager %s ", licence, licence) + } + + var at secrets.AtRest + err = tx.QueryRow(ctx, + `select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence). + Scan(&at.Token, &at.WrappedKey, &at.ManagerKey) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf( + "%q has no refresh token stored yet; its manager %s adopts one first "+ + "(novox/hq ADR 0050, Phase C)", licence, *manager) + } + if err != nil { + return 0, err + } + + result, err := refresher.Refresh(ctx, + adapters.RefreshInput{Licence: licence, Manager: *manager, AtRest: at}) + if err != nil { + return 0, err + } + if strings.TrimSpace(result.AccessToken) == "" { + return 0, fmt.Errorf( + "the refresh produced no access token for %q, so nothing was resealed", licence) + } + + // Reseal the new access token to the holders that exist now — the same set Accept seals to — and + // keep no readable copy. + holders, err := holdersTx(ctx, tx, licence) + if err != nil { + return 0, err + } + sealed := 0 + for _, h := range holders { + key, err := keys(h.Node) + if err != nil { + return 0, err + } + if key == "" { + return 0, fmt.Errorf( + "%s has no sealing key, so the new access token cannot be sealed to it", h.Node) + } + blob, err := secrets.Seal(key, []byte(result.AccessToken)) + if err != nil { + return 0, err + } + if _, err := tx.Exec(ctx, + `update licence_holder set sealed = $4, node_key = $5 + where licence = $1 and node = $2 and module = $3`, + h.Licence, h.Node, h.Module, blob, key); err != nil { + return 0, err + } + sealed++ + } + + // The refresh token stays put unless the vendor rotated it, in which case the refresher returned + // it already re-encrypted at rest — replaced here without ever being seen in the clear. + if result.NewAtRest != nil { + if result.NewAtRest.Token == "" || result.NewAtRest.WrappedKey == "" || + result.NewAtRest.ManagerKey == "" { + return 0, fmt.Errorf( + "the refresh returned an incomplete re-sealed refresh token for %q", licence) + } + if _, err := tx.Exec(ctx, + `update refresh_grant set token = $2, wrapped_key = $3, manager_key = $4, updated_at = now() + where licence = $1`, + licence, result.NewAtRest.Token, result.NewAtRest.WrappedKey, + result.NewAtRest.ManagerKey); err != nil { + return 0, err + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, err + } + return sealed, nil +} + +// holdersTx reads a licence's holders inside a transaction, so the reseal set is consistent under +// the refresh lease. +func holdersTx(ctx context.Context, tx pgx.Tx, licence string) ([]Holder, error) { + rows, err := tx.Query(ctx, + `select licence, node, module, coalesce(sealed, '') from licence_holder + where licence = $1 order by node, module`, licence) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Holder + for rows.Next() { + var h Holder + if err := rows.Scan(&h.Licence, &h.Node, &h.Module, &h.Sealed); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + // Names is every licence's name, sorted — what a refusal lists when a consumer has not chosen. func Names(all []Licence) []string { out := make([]string, 0, len(all)) diff --git a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql index b22e749..389567d 100644 --- a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql +++ b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql @@ -19,6 +19,12 @@ create table licence ( -- What a consumer needs to know that is not secret -- a base URL, a model name. The key is -- never here. serves jsonb not null default '{}'::jsonb, + -- The one node that holds this licence's refresh token readably, and refreshes it (novox/hq + -- ADR 0050's carve-out). Null for a static-key licence, which has nothing to refresh and no + -- manager -- and the null is the check that a static key never grows a readable-at-rest value. + -- A name, not a foreign key: nodes live in another context this one may not join across + -- (novox/hq ADR 0008). + manager text, added_at timestamptz not null default now() ); @@ -41,3 +47,33 @@ create table licence_holder ( added_at timestamptz not null default now(), primary key (licence, node, module) ); + +-- The refresh token, encrypted at rest under the manager node's key. +-- +-- **novox/hq ADR 0050's carve-out, and its one home.** A `refreshable-grant` licence cannot be both +-- sealed so the mesh cannot read it and rotated centrally, because rotating means a node reads the +-- refresh token back. So exactly one node -- the licence's manager -- holds it readably, and it is +-- kept here as an envelope only that node can open: a symmetric data key encrypts the token +-- (secretbox), and the data key is sealed to the manager's public key. This database on its own +-- holds ciphertext and a wrapped key with no private half to open either (novox/hq ADR 0004). +-- +-- **Separate from the access tokens.** licence_holder.sealed is the ACCESS token, sealed per holder +-- and delivered. This is the REFRESH token, one per licence, never delivered to anybody. Keeping +-- them in different tables is what makes "the refresh token is stripped on delivery" structural: +-- delivery reads licence_holder, and the refresh token is not in it. +create table refresh_grant ( + -- One refresh token per licence. On delete cascade: forgetting a licence forgets its refresh + -- token with it, the same way it forgets its holders. + licence text primary key references licence(name) on delete cascade, + + -- base64( nonce || secretbox(data_key, refresh_token) ) -- the token under the symmetric key. + token text not null, + -- base64( anonymous-box(manager_key, data_key) ) -- the data key closed to the manager node. + wrapped_key text not null, + -- The manager's public sealing key the data key was wrapped to. Kept so a manager that + -- regenerated its key can be told it can no longer open this, rather than discovering it as a + -- refresh that will not decrypt (the same reason licence_holder.node_key is kept). + manager_key text not null, + + updated_at timestamptz not null default now() +); diff --git a/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql b/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql new file mode 100644 index 0000000..15c754d --- /dev/null +++ b/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql @@ -0,0 +1,19 @@ +-- A manager, and the refresh token it holds encrypted at rest. +-- +-- novox/hq ADR 0050, Phase B. The consolidated schema (0001) now creates the `manager` column and +-- the `refresh_grant` table; this migration carries an existing database the same distance, so a +-- database that predates the carve-out gains exactly what a fresh one is created with. +-- +-- **Guarded, so it is a no-op on a database created after 0001 was updated.** A fresh database +-- already has both, and re-adding them would fail; `if not exists` on both makes the two paths -- +-- fresh and pre-existing -- end at the same schema. + +alter table licence add column if not exists manager text; + +create table if not exists refresh_grant ( + licence text primary key references licence(name) on delete cascade, + token text not null, + wrapped_key text not null, + manager_key text not null, + updated_at timestamptz not null default now() +); diff --git a/internal/licences/refresh_test.go b/internal/licences/refresh_test.go new file mode 100644 index 0000000..4819a4e --- /dev/null +++ b/internal/licences/refresh_test.go @@ -0,0 +1,331 @@ +package licences + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "strings" + "testing" + + "golang.org/x/crypto/nacl/box" + + "github.com/novox/mesh-control/internal/licences/adapters" + "github.com/novox/mesh-control/internal/secrets" +) + +// nodeKeyPair is a node's key as the node would hold it: the public half the mesh seals to, and an +// open closure holding the private half the mesh never sees. +func nodeKeyPair(t *testing.T) (public string, open func(string) ([]byte, error)) { + t.Helper() + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + var pub, sk [32]byte + copy(pub[:], priv.PublicKey().Bytes()) + copy(sk[:], priv.Bytes()) + return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), + func(sealed string) ([]byte, error) { + blob, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + return nil, err + } + out, ok := box.OpenAnonymous(nil, blob, &pub, &sk) + if !ok { + return nil, context.Canceled // any error; the test only checks success/failure + } + return out, nil + } +} + +// managerPair is like nodeKeyPair but also returns the private key string, because the manager needs +// to OpenAtRest its own refresh token — the one node that reads it back. +func managerPair(t *testing.T) (public, private string) { + t.Helper() + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), + base64.StdEncoding.EncodeToString(priv.Bytes()) +} + +type fakeRefresher struct { + access string + newAtRest *secrets.AtRest + got adapters.RefreshInput +} + +func (f *fakeRefresher) Refresh(_ context.Context, in adapters.RefreshInput) (adapters.RefreshResult, error) { + f.got = in + return adapters.RefreshResult{AccessToken: f.access, NewAtRest: f.newAtRest}, nil +} + +// A refreshable-grant licence set up end to end: a manager, a refresh token sealed at rest to it, two +// holders each with a sealing key, and a fake vendor refresher plugged in. +func aRefreshableLicence(t *testing.T, held *Licences, ctx context.Context, fake *fakeRefresher) ( + managerPub, managerPriv string, holders map[string]func(string) ([]byte, error), keys SealingKeys, +) { + t.Helper() + adapters.RegisterRefresher("anthropic", fake) + t.Cleanup(func() { adapters.RegisterRefresher("anthropic", nil) }) + + if err := held.Add(ctx, "personal", "anthropic", map[string]any{"model": "a-model"}); err != nil { + t.Fatal(err) + } + if err := held.SetManager(ctx, "personal", "workstation"); err != nil { + t.Fatal(err) + } + + managerPub, managerPriv = managerPair(t) + at, err := secrets.SealAtRest("rt-the-refresh-token", managerPub) + if err != nil { + t.Fatal(err) + } + if err := held.SetRefreshGrant(ctx, "personal", at); err != nil { + t.Fatal(err) + } + + holders = map[string]func(string) ([]byte, error){} + pub := map[string]string{} + for _, node := range []string{"workstation", "laptop"} { + p, open := nodeKeyPair(t) + pub[node], holders[node] = p, open + if err := held.Use(ctx, "personal", node, "assistant"); err != nil { + t.Fatal(err) + } + } + keys = func(node string) (string, error) { return pub[node], nil } + return managerPub, managerPriv, holders, keys +} + +// The point of the phase, in one test: a refresh seals the ACCESS token to every holder, and the +// refresh token is nowhere a holder can reach it. +func TestARefreshDeliversTheAccessTokenAndNeverTheRefreshToken(t *testing.T) { + held, ctx := fresh(t) + fake := &fakeRefresher{access: "at-brand-new-access-token"} + _, _, holders, keys := aRefreshableLicence(t, held, ctx, fake) + + sealed, err := held.Refresh(ctx, "personal", keys) + if err != nil { + t.Fatal(err) + } + if sealed != 2 { + t.Fatalf("%d holder(s) were resealed, expected 2", sealed) + } + + for node, open := range holders { + blob, err := held.KeyFor(ctx, "personal", node, "assistant") + if err != nil { + t.Fatal(err) + } + if blob == "" { + t.Fatalf("%s got no access token", node) + } + got, err := open(blob) + if err != nil { + t.Fatalf("%s cannot open what it was delivered", node) + } + if string(got) != "at-brand-new-access-token" { + t.Fatalf("%s was delivered %q, not the access token", node, got) + } + // The refresh token is not in the holder's delivery, opened or sealed. + if string(got) == "rt-the-refresh-token" || strings.Contains(blob, "rt-the-refresh-token") { + t.Fatalf("%s was delivered the refresh token", node) + } + } +} + +// KeyFor delivers the access token and cannot deliver the refresh token, because the refresh token +// is not in licence_holder at all — the stripping is structural. +func TestKeyForNeverCarriesTheRefreshToken(t *testing.T) { + held, ctx := fresh(t) + fake := &fakeRefresher{access: "at-access"} + _, _, _, keys := aRefreshableLicence(t, held, ctx, fake) + if _, err := held.Refresh(ctx, "personal", keys); err != nil { + t.Fatal(err) + } + + // Every holder row, straight from the store: none of them holds the refresh token in any form. + rows, err := held.store.Pool().Query(ctx, + `select coalesce(sealed, '') from licence_holder where licence = 'personal'`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var sealed string + if err := rows.Scan(&sealed); err != nil { + t.Fatal(err) + } + if strings.Contains(sealed, "rt-the-refresh-token") { + t.Fatal("a holder row carries the refresh token") + } + } +} + +// The refresh token at rest is not readable from the database alone: the row holds ciphertext and a +// wrapped key, and only the manager node's private half opens it. +func TestTheRefreshTokenAtRestNeedsTheManagersKey(t *testing.T) { + held, ctx := fresh(t) + fake := &fakeRefresher{access: "at-access"} + managerPub, managerPriv, _, _ := aRefreshableLicence(t, held, ctx, fake) + + // What the database holds, read straight from the row. + var token, wrapped, managerKey string + if err := held.store.Pool().QueryRow(ctx, + `select token, wrapped_key, manager_key from refresh_grant where licence = 'personal'`). + Scan(&token, &wrapped, &managerKey); err != nil { + t.Fatal(err) + } + if strings.Contains(token, "rt-the-refresh-token") || strings.Contains(wrapped, "rt-the-refresh-token") { + t.Fatal("the refresh token is in the row in the clear") + } + + // The manager, holding its private key, reads it back. + got, err := secrets.OpenAtRest( + secrets.AtRest{Token: token, WrappedKey: wrapped, ManagerKey: managerKey}, + managerPub, managerPriv) + if err != nil { + t.Fatal(err) + } + if got != "rt-the-refresh-token" { + t.Fatalf("the manager read back %q", got) + } + + // Another node cannot, which is the whole of "the manager node only". + otherPub, otherPriv := managerPair(t) + if _, err := secrets.OpenAtRest( + secrets.AtRest{Token: token, WrappedKey: wrapped, ManagerKey: managerKey}, + otherPub, otherPriv); err == nil { + t.Fatal("a node that is not the manager opened the refresh token") + } +} + +// After a refresh, holders hold a NEW access token, and the refresh token that was not rotated is +// unchanged — never delivered either way. +func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing.T) { + held, ctx := fresh(t) + fake := &fakeRefresher{access: "at-first"} + _, _, holders, keys := aRefreshableLicence(t, held, ctx, fake) + + // A prior access token, so we can see it change. + if _, err := held.Refresh(ctx, "personal", keys); err != nil { + t.Fatal(err) + } + before := map[string]string{} + for node := range holders { + blob, err := held.KeyFor(ctx, "personal", node, "assistant") + if err != nil { + t.Fatal(err) + } + before[node] = blob + } + grantBefore := grantRow(t, held, ctx) + + // A second refresh with a different access token and no rotation of the refresh token. + fake.access = "at-second" + if _, err := held.Refresh(ctx, "personal", keys); err != nil { + t.Fatal(err) + } + for node, open := range holders { + blob, err := held.KeyFor(ctx, "personal", node, "assistant") + if err != nil { + t.Fatal(err) + } + if blob == before[node] { + t.Fatalf("%s was not given a new sealed access token", node) + } + got, err := open(blob) + if err != nil { + t.Fatal(err) + } + if string(got) != "at-second" { + t.Fatalf("%s holds %q, not the new access token", node, got) + } + } + if grantRow(t, held, ctx) != grantBefore { + t.Fatal("the refresh token changed although the vendor did not rotate it") + } +} + +// When the vendor rotates the refresh token too, the stored envelope is replaced with the +// re-encrypted one — and it is still not deliverable to a holder. +func TestARotatedRefreshTokenReplacesTheStoredEnvelope(t *testing.T) { + held, ctx := fresh(t) + fake := &fakeRefresher{access: "at-access"} + managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, fake) + + grantBefore := grantRow(t, held, ctx) + + rotated, err := secrets.SealAtRest("rt-a-rotated-refresh-token", managerPub) + if err != nil { + t.Fatal(err) + } + fake.newAtRest = &rotated + if _, err := held.Refresh(ctx, "personal", keys); err != nil { + t.Fatal(err) + } + + if grantRow(t, held, ctx) == grantBefore { + t.Fatal("the rotated refresh token did not replace the stored envelope") + } + at, ok, err := held.RefreshGrant(ctx, "personal") + if err != nil || !ok { + t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err) + } + got, err := secrets.OpenAtRest(at, managerPub, managerPriv) + if err != nil { + t.Fatal(err) + } + if got != "rt-a-rotated-refresh-token" { + t.Fatalf("the stored grant opened to %q, not the rotated token", got) + } +} + +// A static-key licence has no refresh token and the carve-out never fires: it has no manager, and it +// cannot be refreshed. +func TestAStaticKeyLicenceHasNoManagerAndNoRefresh(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "plain", "anthropic-api-key", nil); err != nil { + t.Fatal(err) + } + if err := held.SetManager(ctx, "plain", "workstation"); err == nil { + t.Fatal("a static-key licence was given a manager") + } + if _, ok, err := held.RefreshGrant(ctx, "plain"); err != nil || ok { + t.Fatalf("a static-key licence has a refresh token stored: ok=%v err=%v", ok, err) + } + if _, err := held.Refresh(ctx, "plain", func(string) (string, error) { return "", nil }); err == nil { + t.Fatal("a static-key licence was refreshed") + } +} + +// A refreshable licence with no manager named cannot be refreshed, and says how to name one. +func TestARefreshableLicenceWithoutAManagerIsRefused(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "personal", "anthropic", nil); err != nil { + t.Fatal(err) + } + _, err := held.Refresh(ctx, "personal", func(string) (string, error) { return "", nil }) + if err == nil { + t.Fatal("a licence with no manager was refreshed") + } + if !strings.Contains(err.Error(), "manager") { + t.Fatalf("the refusal does not point at the missing manager: %v", err) + } +} + +// grantRow is the whole at-rest envelope as one string, for asserting it changed or did not. +func grantRow(t *testing.T, held *Licences, ctx context.Context) string { + t.Helper() + at, ok, err := held.RefreshGrant(ctx, "personal") + if err != nil { + t.Fatal(err) + } + if !ok { + return "" + } + return at.Token + "|" + at.WrappedKey + "|" + at.ManagerKey +} diff --git a/internal/secrets/atrest.go b/internal/secrets/atrest.go new file mode 100644 index 0000000..d095272 --- /dev/null +++ b/internal/secrets/atrest.go @@ -0,0 +1,160 @@ +package secrets + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/nacl/box" + "golang.org/x/crypto/nacl/secretbox" +) + +// A value the mesh keeps encrypted so ONE node — and nothing else, not this database on its own — +// can read it back. +// +// **Why this exists at all, and why it is not the seal above.** The per-holder seal (Seal / Make / +// Accept) is one-way delivery: the mesh closes a value to a node's public key, the node opens it +// once with the private half the mesh never saw, and the mesh keeps nothing it can read. That is +// the whole guarantee, and for every credential the mesh handles it is the right one — there is +// nothing to rotate, so nothing has to be read back. +// +// A `refreshable-grant` credential (novox/hq ADR 0050) breaks that, and the ADR says so in as many +// words: it cannot be *sealed so the mesh cannot read it* and *rotated centrally* at once, because +// rotating it means some node reads the refresh token back, repeatedly, every time the grant is +// refreshed. The carve-out the ADR draws is exactly and only this: the **manager node** holds the +// refresh token **encrypted at rest**, readable **by that node**, because rotation requires it. +// +// So this is a genuinely different mechanism from the anonymous-box seal, not a second caller of it: +// +// - The payload is under a **symmetric** data key (NaCl secretbox), because the same node decrypts +// it again and again — an anonymous sealed box is nonce-less one-shot delivery, not a store its +// writer reopens. +// - Only the **data key** is sealed to the manager's public sealing key, with the very same +// anonymous box the per-holder seal uses (Seal, below). This is envelope encryption: the bulk +// is symmetric so it can be reopened, the key is asymmetric so only the manager can recover it. +// +// **Why this database alone cannot read it.** What is stored is the secretbox ciphertext and the +// data key *wrapped to the manager node's public sealing key*. Recovering the data key needs the +// manager node's Curve25519 private half, which never leaves that machine (novox/hq ADR 0004) and +// which the control plane has never held. A copy of this database is therefore a directory of +// ciphertexts and wrapped keys with nothing to open either — which is the property a plain +// encrypted-at-rest column does not have, because there the key sits beside the data. +// +// **Where each half runs.** SealAtRest and OpenAtRest are the mechanism, kept here in one audited +// place. In production only the **manager node** runs them — it produces the envelope when the grant +// is first adopted, and opens it to refresh (novox/hq ADR 0050, Phase C). The control plane stores +// and forwards the envelope as an opaque blob and never calls OpenAtRest on a live path; it holds no +// private key that could. OpenAtRest lives here so the round trip and the security bounds are +// testable, and so the manager-side code has one implementation to reuse rather than a second to +// keep in step. +type AtRest struct { + // Token is base64( nonce ‖ secretbox(dataKey, plaintext) ) — the refresh token under the + // symmetric data key, the nonce carried in front of the box as its convention allows. + Token string + // WrappedKey is base64( anonymous-box(managerSealingKey, dataKey) ) — the data key closed to the + // manager node, openable only by that node's private half. + WrappedKey string + // ManagerKey is the manager's public sealing key the data key was wrapped to. Kept for the same + // reason licence_holder.node_key and module_secret.node_key are: a manager that has since + // regenerated its key can be told it can no longer open this, rather than discovering it as a + // refresh that fails to decrypt. + ManagerKey string +} + +// SealAtRest wraps a value so only the holder of managerSealingKey's private half can read it. +// +// A fresh random data key each time, so two envelopes of the same refresh token look nothing alike +// and a rotation that changed nothing is indistinguishable from one that changed everything — the +// same property the per-holder seal has, kept here deliberately. +func SealAtRest(value, managerSealingKey string) (AtRest, error) { + if strings.TrimSpace(value) == "" { + return AtRest{}, fmt.Errorf("there is nothing to seal") + } + if managerSealingKey == "" { + return AtRest{}, fmt.Errorf( + "the manager has no sealing key, so a refresh token cannot be kept for it") + } + + var dataKey [32]byte + if _, err := rand.Read(dataKey[:]); err != nil { + return AtRest{}, err + } + // Zeroed on the way out. The plaintext data key exists for the length of this call and no + // longer, which is what keeps the envelope's secrecy resting on the wrapped copy alone. + defer func() { + for i := range dataKey { + dataKey[i] = 0 + } + }() + + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return AtRest{}, err + } + // secretbox.Seal prepends nothing; the nonce is our prefix, carried so OpenAtRest can recover it. + sealedToken := secretbox.Seal(nonce[:], []byte(value), &nonce, &dataKey) + + wrapped, err := Seal(managerSealingKey, dataKey[:]) + if err != nil { + return AtRest{}, err + } + + return AtRest{ + Token: base64.StdEncoding.EncodeToString(sealedToken), + WrappedKey: wrapped, + ManagerKey: managerSealingKey, + }, nil +} + +// OpenAtRest recovers the value, given the manager node's own key pair. +// +// This is the manager-node / test half of the mechanism (see the type comment): the control plane +// has no private key and never calls it on a live path. +func OpenAtRest(a AtRest, managerPublicKey, managerPrivateKey string) (string, error) { + pub, err := base64.StdEncoding.DecodeString(managerPublicKey) + if err != nil || len(pub) != 32 { + return "", fmt.Errorf("%q is not a sealing key", managerPublicKey) + } + priv, err := base64.StdEncoding.DecodeString(managerPrivateKey) + if err != nil || len(priv) != 32 { + return "", fmt.Errorf("the manager private key is not 32 bytes") + } + var pubArr, privArr [32]byte + copy(pubArr[:], pub) + copy(privArr[:], priv) + + wrapped, err := base64.StdEncoding.DecodeString(a.WrappedKey) + if err != nil { + return "", fmt.Errorf("the wrapped key is not base64: %w", err) + } + keyBytes, ok := box.OpenAnonymous(nil, wrapped, &pubArr, &privArr) + if !ok { + return "", fmt.Errorf("this refresh token was not wrapped to this manager's key") + } + if len(keyBytes) != 32 { + return "", fmt.Errorf("the wrapped key is the wrong length") + } + var dataKey [32]byte + copy(dataKey[:], keyBytes) + defer func() { + for i := range dataKey { + dataKey[i] = 0 + } + }() + + raw, err := base64.StdEncoding.DecodeString(a.Token) + if err != nil { + return "", fmt.Errorf("the sealed token is not base64: %w", err) + } + if len(raw) < 24 { + return "", fmt.Errorf("the sealed token is too short to hold a nonce") + } + var nonce [24]byte + copy(nonce[:], raw[:24]) + out, ok := secretbox.Open(nil, raw[24:], &nonce, &dataKey) + if !ok { + return "", fmt.Errorf("the refresh token would not open under its data key") + } + return string(out), nil +} diff --git a/internal/secrets/atrest_test.go b/internal/secrets/atrest_test.go new file mode 100644 index 0000000..1515298 --- /dev/null +++ b/internal/secrets/atrest_test.go @@ -0,0 +1,106 @@ +package secrets + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "strings" + "testing" +) + +// managerKey is the manager node's key pair, as the node would hold it: the public half reported to +// the mesh, the private half kept and used only here. +func managerKey(t *testing.T) (public, private string) { + t.Helper() + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), + base64.StdEncoding.EncodeToString(priv.Bytes()) +} + +// The whole carve-out in one test: the manager, and only the manager, reads its refresh token back. +func TestOnlyTheManagerOpensAnAtRestValue(t *testing.T) { + pub, priv := managerKey(t) + const refresh = "rt-a-real-looking-refresh-token" + + at, err := SealAtRest(refresh, pub) + if err != nil { + t.Fatal(err) + } + got, err := OpenAtRest(at, pub, priv) + if err != nil { + t.Fatal(err) + } + if got != refresh { + t.Fatalf("the refresh token did not survive: %q", got) + } + + // Another node's key pair cannot open it — the wrapped data key is closed to the manager alone. + otherPub, otherPriv := managerKey(t) + if _, err := OpenAtRest(at, otherPub, otherPriv); err == nil { + t.Fatal("a different node opened the manager's refresh token") + } +} + +// The database on its own — the ciphertext and the wrapped key, and nothing else — carries neither +// the refresh token nor the symmetric key that would open it. This is the property a plain +// encrypted-at-rest column does not have, and the reason the carve-out is bounded to the manager. +func TestTheEnvelopeAloneRevealsNothing(t *testing.T) { + pub, _ := managerKey(t) + const refresh = "rt-the-long-lived-secret" + + at, err := SealAtRest(refresh, pub) + if err != nil { + t.Fatal(err) + } + for what, field := range map[string]string{ + "the ciphertext": at.Token, + "the wrapped key": at.WrappedKey, + } { + if strings.Contains(field, refresh) { + t.Fatalf("%s holds the refresh token in the clear", what) + } + } + // Two seals of one token look nothing alike: a fresh data key and nonce each time. + again, err := SealAtRest(refresh, pub) + if err != nil { + t.Fatal(err) + } + if at.Token == again.Token { + t.Fatal("two seals of the same token are identical, so the storage says they are the same") + } +} + +// A tampered ciphertext does not open. secretbox authenticates, so a flipped byte is caught rather +// than yielding a quietly wrong token. +func TestATamperedEnvelopeIsRefused(t *testing.T) { + pub, priv := managerKey(t) + at, err := SealAtRest("rt-value", pub) + if err != nil { + t.Fatal(err) + } + + raw, err := base64.StdEncoding.DecodeString(at.Token) + if err != nil { + t.Fatal(err) + } + raw[len(raw)-1] ^= 0x01 + at.Token = base64.StdEncoding.EncodeToString(raw) + + if _, err := OpenAtRest(at, pub, priv); err == nil { + t.Fatal("a tampered refresh token opened as though it were intact") + } +} + +// Nothing to seal, and no key to seal to, are both refused rather than stored as a working envelope. +func TestSealAtRestRefusesTheEmptyCases(t *testing.T) { + pub, _ := managerKey(t) + if _, err := SealAtRest("", pub); err == nil { + t.Fatal("an empty value was sealed at rest") + } + if _, err := SealAtRest("rt-value", ""); err == nil { + t.Fatal("a refresh token was sealed to a manager with no key") + } +}