Merge pull request 'model-access B: the refreshable-grant machinery (ADR 0050 carve-out)' (#14) from feat/model-access-refreshable into main
This commit was merged in pull request #14.
This commit is contained in:
@@ -18,7 +18,7 @@ import (
|
|||||||
// them too, because the whole point is saying which one a given consumer uses.
|
// them too, because the whole point is saying which one a given consumer uses.
|
||||||
func licenceCommand(ctx context.Context, args []string) error {
|
func licenceCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
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] {
|
switch args[0] {
|
||||||
case "add":
|
case "add":
|
||||||
@@ -31,10 +31,15 @@ func licenceCommand(ctx context.Context, args []string) error {
|
|||||||
return licenceUse(ctx, args[1:], false)
|
return licenceUse(ctx, args[1:], false)
|
||||||
case "key":
|
case "key":
|
||||||
return licenceKey(ctx, args[1:])
|
return licenceKey(ctx, args[1:])
|
||||||
|
case "manager":
|
||||||
|
return licenceManager(ctx, args[1:])
|
||||||
|
case "refresh":
|
||||||
|
return licenceRefresh(ctx, args[1:])
|
||||||
case "forget":
|
case "forget":
|
||||||
return licenceForget(ctx, args[1:])
|
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 {
|
func licenceAdd(ctx context.Context, args []string) error {
|
||||||
@@ -224,6 +229,66 @@ func licenceKey(ctx context.Context, args []string) error {
|
|||||||
return nil
|
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>")
|
||||||
|
}
|
||||||
|
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>")
|
||||||
|
}
|
||||||
|
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 {
|
func licenceForget(ctx context.Context, args []string) error {
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
return errors.New("licence forget <name>")
|
return errors.New("licence forget <name>")
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ func usage() {
|
|||||||
builds [<module>] what has been built lately, and what came of it
|
builds [<module>] what has been built lately, and what came of it
|
||||||
builder issue <name> a broker account for a build machine, scoped to build work
|
builder issue <name> 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 add|list|use|key model access, under the name a person calls it
|
||||||
|
licence manager <name> <node> the node that holds a refreshable licence's refresh token
|
||||||
|
licence refresh <name> mint a new access token and seal it to every holder
|
||||||
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
||||||
pin <node> <provision> <from> which node this one gets a provision from
|
pin <node> <provision> <from> which node this one gets a provision from
|
||||||
unpin <node> <provision> put that question back
|
unpin <node> <provision> put that question back
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
// vendor answers, and the lifecycle that vendor's credential needs, lives here — exactly as
|
// 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).
|
// `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
|
// **Two shapes.** A `static-key` vendor implements almost nothing: the credential is an
|
||||||
// credential is an operator-supplied value, sealed to each holder by the generic anonymous box
|
// operator-supplied value, sealed to each holder by the generic anonymous box (ADR 0024) and
|
||||||
// (ADR 0024) and delivered unchanged. The refreshable-grant machinery — central rotation, an
|
// delivered unchanged. A `refreshable-grant` vendor carries the bounded carve-out — a manager node
|
||||||
// identity guard, a usage reading, refresh-token-stripped delivery — is Phase B, and its verbs are
|
// holds the refresh token encrypted at rest, access tokens are still sealed per holder, and the
|
||||||
// named here as optional capabilities (Refresher, Identifier, UsageReader) so the seam exists
|
// refresh token is never in a holder's delivery. This package holds the generic half of both. The
|
||||||
// before the code does. The abstraction earns its keep by making the common vendor small, not the
|
// vendor-specific half of a refresh — the actual OAuth call against a vendor's endpoint — is a
|
||||||
// rare one clever (ADR 0050).
|
// 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
|
package adapters
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/novox/mesh-control/internal/secrets"
|
"github.com/novox/mesh-control/internal/secrets"
|
||||||
)
|
)
|
||||||
@@ -55,11 +58,72 @@ type Adapter interface {
|
|||||||
Deliver(sealed string) string
|
Deliver(sealed string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresher is implemented only by a refreshable-grant adapter (ADR 0050, Phase B): the
|
// RefreshInput is what producing a new access token needs, and all a refresh is given.
|
||||||
// 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.
|
// 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 {
|
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
|
// 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.
|
// 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
|
// The single place a vendor is taught to the mesh. anthropic's real credential is a subscription
|
||||||
// static-key vendor, which is what the lab bed exercises.
|
// 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
|
// TODO(Phase C, ADR 0050): register anthropic's real VendorRefresher — the OAuth refresh against the
|
||||||
// becomes RefreshableGrant and a refreshable-grant adapter is registered for it; the static-key
|
// vendor's endpoint — with RegisterRefresher. Until then a refresh on it is refused, naming Phase C.
|
||||||
// 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.
|
|
||||||
var registry = map[string]Shape{
|
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.
|
// For returns the adapter for a vendor, refusing an unknown one clearly.
|
||||||
@@ -115,12 +180,15 @@ func For(vendor string) (Adapter, error) {
|
|||||||
switch shape {
|
switch shape {
|
||||||
case StaticKey:
|
case StaticKey:
|
||||||
return staticKey{vendor: vendor}, nil
|
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:
|
default:
|
||||||
// A vendor registered with a shape this build does not implement yet — the refreshable-grant
|
// A shape this build has no adapter for at all. Said plainly rather than answered with an
|
||||||
// seam. Said plainly rather than answered with a static-key adapter that would silently
|
// adapter that would silently mishandle it.
|
||||||
// mishandle it.
|
|
||||||
return nil, fmt.Errorf(
|
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
|
// unchanged — there is no vendor step between the store and the node, and the mesh never sees the
|
||||||
// plaintext.
|
// plaintext.
|
||||||
func (s staticKey) Deliver(sealed string) string { return sealed }
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/novox/mesh-control/internal/licences/adapters"
|
"github.com/novox/mesh-control/internal/licences/adapters"
|
||||||
|
"github.com/novox/mesh-control/internal/secrets"
|
||||||
"github.com/novox/mesh-control/internal/store"
|
"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
|
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 <node>", 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.
|
// Names is every licence's name, sorted — what a refusal lists when a consumer has not chosen.
|
||||||
func Names(all []Licence) []string {
|
func Names(all []Licence) []string {
|
||||||
out := make([]string, 0, len(all))
|
out := make([]string, 0, len(all))
|
||||||
|
|||||||
@@ -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
|
-- What a consumer needs to know that is not secret -- a base URL, a model name. The key is
|
||||||
-- never here.
|
-- never here.
|
||||||
serves jsonb not null default '{}'::jsonb,
|
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()
|
added_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -41,3 +47,33 @@ create table licence_holder (
|
|||||||
added_at timestamptz not null default now(),
|
added_at timestamptz not null default now(),
|
||||||
primary key (licence, node, module)
|
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()
|
||||||
|
);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user