model-access: refreshable-grant — manager holds the refresh token, control plane never reads it #15
+180
-9
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -18,7 +19,8 @@ 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|manager|refresh|forget")
|
return errors.New(
|
||||||
|
"licence add|list|use|release|key|manager|set-grant|refresh|submit-refresh|forget")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "add":
|
case "add":
|
||||||
@@ -33,13 +35,18 @@ func licenceCommand(ctx context.Context, args []string) error {
|
|||||||
return licenceKey(ctx, args[1:])
|
return licenceKey(ctx, args[1:])
|
||||||
case "manager":
|
case "manager":
|
||||||
return licenceManager(ctx, args[1:])
|
return licenceManager(ctx, args[1:])
|
||||||
|
case "set-grant":
|
||||||
|
return licenceSetGrant(ctx, args[1:])
|
||||||
case "refresh":
|
case "refresh":
|
||||||
return licenceRefresh(ctx, args[1:])
|
return licenceRefresh(ctx, args[1:])
|
||||||
|
case "submit-refresh":
|
||||||
|
return licenceSubmitRefresh(ctx, args[1:])
|
||||||
case "forget":
|
case "forget":
|
||||||
return licenceForget(ctx, args[1:])
|
return licenceForget(ctx, args[1:])
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"licence %q; it is add, list, use, release, key, manager, refresh or forget", args[0])
|
"licence %q; it is add, list, use, release, key, manager, set-grant, refresh, "+
|
||||||
|
"submit-refresh or forget", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
func licenceAdd(ctx context.Context, args []string) error {
|
func licenceAdd(ctx context.Context, args []string) error {
|
||||||
@@ -237,22 +244,186 @@ func licenceKey(ctx context.Context, args []string) error {
|
|||||||
// nothing — the absent manager is part of what keeps a static key from ever holding a value readably
|
// nothing — the absent manager is part of what keeps a static key from ever holding a value readably
|
||||||
// at rest.
|
// at rest.
|
||||||
func licenceManager(ctx context.Context, args []string) error {
|
func licenceManager(ctx context.Context, args []string) error {
|
||||||
if len(args) != 2 {
|
if len(args) != 3 {
|
||||||
return errors.New("licence manager <name> <node>")
|
return errors.New("licence manager <name> <node> <module>")
|
||||||
}
|
}
|
||||||
name, node := args[0], args[1]
|
name, node, module := args[0], args[1], args[2]
|
||||||
|
|
||||||
held, err := openLicences(ctx)
|
held, err := openLicences(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer held.Close()
|
defer held.Close()
|
||||||
if err := held.SetManager(ctx, name, node); err != nil {
|
if err := held.SetManager(ctx, name, node, module); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s holds and refreshes %s.\n"+
|
fmt.Printf("%s on %s holds and refreshes %s.\n"+
|
||||||
" Its refresh token is kept encrypted at rest, readable by %s alone — no other node, and "+
|
" Its refresh token is sealed to %s's key — readable by that node alone, not by any other "+
|
||||||
"not this database on its own.\n", node, name, node)
|
"node and not by this database. Put %s on the licence too so it is delivered the token:\n"+
|
||||||
|
" licence use %s %s %s\n", module, node, name, node, module, name, node, module)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealedGrantJSON is the wire shape of a sealed refresh token on this command surface: an anonymous
|
||||||
|
// sealed box and the public key it was sealed to, and nothing else.
|
||||||
|
//
|
||||||
|
// **Every field of it is ciphertext or a public key.** `sealed` is the refresh token as a
|
||||||
|
// `crypto_box_seal` to the manager node's public key; `manager_key` is that public key. Neither is
|
||||||
|
// the refresh token in the clear — which is why this surface may read one in (`set-grant`,
|
||||||
|
// `submit-refresh`) without the control plane ever holding a refresh token it could read. The manager
|
||||||
|
// module, on the manager node, seals it; the HOST, on that node, unseals it to deliver cleartext. This
|
||||||
|
// database, and this surface, only ever forward the box (novox/hq ADR 0050).
|
||||||
|
type sealedGrantJSON struct {
|
||||||
|
Sealed string `json:"sealed"`
|
||||||
|
ManagerKey string `json:"manager_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSealedGrant reads a sealed refresh token from a file or standard input as JSON.
|
||||||
|
func readSealedGrant(from string) (sealedGrantJSON, error) {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
if from != "" {
|
||||||
|
raw, err = os.ReadFile(from)
|
||||||
|
} else {
|
||||||
|
raw, err = readAllStdin()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return sealedGrantJSON{}, err
|
||||||
|
}
|
||||||
|
var g sealedGrantJSON
|
||||||
|
if err := json.Unmarshal(raw, &g); err != nil {
|
||||||
|
return sealedGrantJSON{}, fmt.Errorf("the sealed refresh token is not JSON: %w", err)
|
||||||
|
}
|
||||||
|
if g.Sealed == "" || g.ManagerKey == "" {
|
||||||
|
return sealedGrantJSON{}, errors.New(
|
||||||
|
"a sealed refresh token is {sealed, manager_key}, and one part is missing")
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAllStdin() ([]byte, error) {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
return io.ReadAll(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenceSetGrant stores a sealed refresh token the manager module produced — adoption, and the
|
||||||
|
// re-seal after a rotation done outside this process (novox/hq ADR 0050).
|
||||||
|
//
|
||||||
|
// **It takes a sealed box, never a refresh token.** The manager module, on the manager node, reads
|
||||||
|
// the operator's refresh token, seals it to that node's own public key, and hands the box here. So the
|
||||||
|
// one moment a refresh token is in the clear is on the manager node, never in the control plane — the
|
||||||
|
// same bound the whole carve-out keeps. This surface refuses anything that is not a complete sealed
|
||||||
|
// grant rather than storing half of one.
|
||||||
|
func licenceSetGrant(ctx context.Context, args []string) error {
|
||||||
|
set := flag.NewFlagSet("licence set-grant", flag.ContinueOnError)
|
||||||
|
from := set.String("file", "", "read the sealed refresh token from a file instead of standard input")
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(positionals) != 1 {
|
||||||
|
return errors.New("licence set-grant <name> [--file <path>]")
|
||||||
|
}
|
||||||
|
name := positionals[0]
|
||||||
|
|
||||||
|
grant, err := readSealedGrant(*from)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
held, err := openLicences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer held.Close()
|
||||||
|
if err := held.SetRefreshGrant(ctx, name, grant.Sealed, grant.ManagerKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s now holds a refresh token for %s, sealed to that node's key and readable by it "+
|
||||||
|
"alone.\n the control plane stored the box without opening it; run `push` to deliver it\n",
|
||||||
|
"the manager", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenceSubmitRefresh publishes a refresh a MANAGER NODE already performed: the new access token is
|
||||||
|
// sealed to every holder, and a rotated refresh token replaces the stored envelope (novox/hq ADR
|
||||||
|
// 0050, Phase C).
|
||||||
|
//
|
||||||
|
// **This is the boundary the invariant rests on.** The manager runtime, on the manager node, opened
|
||||||
|
// the at-rest envelope with that node's key, called the vendor's OAuth endpoint, and produced this:
|
||||||
|
// the new access token in the clear, and — only if the vendor rotated it — the refresh token already
|
||||||
|
// re-sealed at rest. This reads exactly those two things and no refresh token in the clear ever
|
||||||
|
// reaches it, because it is never given one. The access token is sealed per holder and discarded,
|
||||||
|
// as any accepted key is; the rotated envelope is stored opaque.
|
||||||
|
func licenceSubmitRefresh(ctx context.Context, args []string) error {
|
||||||
|
set := flag.NewFlagSet("licence submit-refresh", flag.ContinueOnError)
|
||||||
|
accessFrom := set.String("access-file", "",
|
||||||
|
"read the new access token from a file instead of standard input")
|
||||||
|
grantFrom := set.String("grant-file", "",
|
||||||
|
"the rotated sealed refresh token, if the vendor rotated it; omit if it did not")
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(positionals) != 1 {
|
||||||
|
return errors.New(
|
||||||
|
"licence submit-refresh <name> [--access-file <path>] [--grant-file <path>]")
|
||||||
|
}
|
||||||
|
name := positionals[0]
|
||||||
|
|
||||||
|
var accessToken string
|
||||||
|
if *accessFrom != "" {
|
||||||
|
raw, err := os.ReadFile(*accessFrom)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accessToken = strings.TrimSpace(string(raw))
|
||||||
|
} else {
|
||||||
|
raw, err := readAllStdin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accessToken = strings.TrimSpace(string(raw))
|
||||||
|
}
|
||||||
|
if accessToken == "" {
|
||||||
|
return errors.New("no access token was given, so there is nothing to seal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rotated sealed token is optional: absent, the stored refresh token is left exactly as it was.
|
||||||
|
var newSealed, newManagerKey string
|
||||||
|
if *grantFrom != "" {
|
||||||
|
grant, err := readSealedGrant(*grantFrom)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newSealed, newManagerKey = grant.Sealed, grant.ManagerKey
|
||||||
|
}
|
||||||
|
|
||||||
|
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.SubmitRefresh(ctx, name, accessToken, newSealed, newManagerKey,
|
||||||
|
func(node string) (string, error) {
|
||||||
|
return inv.SealingKeyOf(ctx, node)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rotatedNote := "the refresh token was left with its manager unchanged"
|
||||||
|
if newSealed != "" {
|
||||||
|
rotatedNote = "the rotated refresh token replaced the stored box, still readable by the " +
|
||||||
|
"manager node alone"
|
||||||
|
}
|
||||||
|
fmt.Printf("submitted a refresh for %s: a new access token sealed to %d holder(s), and %s.\n"+
|
||||||
|
" run `push` to deliver it\n", name, sealed, rotatedNote)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,25 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
return catalogue.Resolution{}, nil, err
|
return catalogue.Resolution{}, nil, err
|
||||||
}
|
}
|
||||||
resolved.Needs[i].Sealed = sealed
|
resolved.Needs[i].Sealed = sealed
|
||||||
|
// If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key
|
||||||
|
// in its bound facts (novox/hq ADR 0050). It is safe to disclose — a public key — and it
|
||||||
|
// is what the manager module needs to re-seal a rotated refresh token to this same node,
|
||||||
|
// having been given no private key of its own. A consumer holder gets none.
|
||||||
|
pub, err := managerPublicKeyFor(ctx, open, inv, n.From, nodeName, n.For)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Resolution{}, nil, err
|
||||||
|
}
|
||||||
|
if pub != "" {
|
||||||
|
serves := map[string]any{}
|
||||||
|
for k, v := range resolved.Needs[i].Serves {
|
||||||
|
serves[k] = v
|
||||||
|
}
|
||||||
|
serves["manager_public_key"] = pub
|
||||||
|
resolved.Needs[i].Serves = serves
|
||||||
|
// The manager holder: its empty pre-adoption refresh token is a waiting state, not a
|
||||||
|
// missing consumer key, so the declaration tolerates it rather than refusing.
|
||||||
|
resolved.Needs[i].Manager = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From)
|
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From)
|
||||||
@@ -685,6 +704,30 @@ func keyFor(ctx context.Context, open *stores, licence, node, module string) (st
|
|||||||
return held.KeyFor(ctx, licence, node, module)
|
return held.KeyFor(ctx, licence, node, module)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the
|
||||||
|
// licence's manager holder — empty otherwise.
|
||||||
|
//
|
||||||
|
// It is delivered to the manager module in its bound facts so it can re-seal a rotated refresh token
|
||||||
|
// to this node (novox/hq ADR 0050). Public, so it travels in the clear like any other bound fact; and
|
||||||
|
// scoped to the manager holder alone, so a consumer never receives it and nothing invites a consumer
|
||||||
|
// to seal anything.
|
||||||
|
func managerPublicKeyFor(
|
||||||
|
ctx context.Context, open *stores, inv *inventory.Inventory, licence, node, module string,
|
||||||
|
) (string, error) {
|
||||||
|
held, err := open.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
managerNode, managerModule, err := held.ManagerOf(ctx, licence)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if managerNode == "" || node != managerNode || module != managerModule {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return inv.SealingKeyOf(ctx, node)
|
||||||
|
}
|
||||||
|
|
||||||
// portsOn is one module's assignments on one machine, by the port the software uses.
|
// portsOn is one module's assignments on one machine, by the port the software uses.
|
||||||
func portsOn(
|
func portsOn(
|
||||||
ctx context.Context, inv *inventory.Inventory, node, module string,
|
ctx context.Context, inv *inventory.Inventory, node, module string,
|
||||||
|
|||||||
@@ -237,12 +237,18 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
found = &r.Needs[i]
|
found = &r.Needs[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found != nil && found.ByRecord && found.Sealed == "" {
|
if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager {
|
||||||
// Answered by a record whose key has not been supplied since this consumer was
|
// Answered by a record whose key has not been supplied since this consumer was
|
||||||
// put on it. **Refused, not skipped.** The mesh discarded the plaintext when the
|
// put on it. **Refused, not skipped.** The mesh discarded the plaintext when the
|
||||||
// key was accepted and cannot seal another, so a machine that resolved cleanly
|
// key was accepted and cannot seal another, so a machine that resolved cleanly
|
||||||
// would receive no file at all and fail at whatever tried to read it — which is
|
// would receive no file at all and fail at whatever tried to read it — which is
|
||||||
// the outcome ADR 0024 exists to avoid, arrived at politely.
|
// the outcome ADR 0024 exists to avoid, arrived at politely.
|
||||||
|
//
|
||||||
|
// The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token
|
||||||
|
// is a licence whose manager has not adopted one yet, a real waiting state rather than
|
||||||
|
// a lost key. It falls through to the skip below — its bound facts (carrying the
|
||||||
|
// manager's public key) are still delivered, which is what adoption needs to seal the
|
||||||
|
// first refresh token.
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"%s on this machine uses the licence %q and no key has been sealed to it. "+
|
"%s on this machine uses the licence %q and no key has been sealed to it. "+
|
||||||
"The mesh cannot make one; supply it again with `licence key %s`",
|
"The mesh cannot make one; supply it again with `licence key %s`",
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ type Needed struct {
|
|||||||
Sealed string
|
Sealed string
|
||||||
// For is the module that wanted it.
|
// For is the module that wanted it.
|
||||||
For string
|
For string
|
||||||
|
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
|
||||||
|
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
|
||||||
|
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
|
||||||
|
// state, not a consumer missing its key. Set by the plan, which is the only layer that knows a
|
||||||
|
// licence's manager; empty for every consumer.
|
||||||
|
Manager bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refusal is why a set of assignments cannot become a declaration.
|
// Refusal is why a set of assignments cannot become a declaration.
|
||||||
|
|||||||
@@ -60,28 +60,32 @@ type Adapter interface {
|
|||||||
|
|
||||||
// RefreshInput is what producing a new access token needs, and all a refresh is given.
|
// 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)
|
// It carries the refresh token **only as its sealed blob** — the caller (the control plane) never
|
||||||
// never holds the refresh token in the clear, because it cannot open the envelope. Opening it, and
|
// holds the refresh token in the clear, because it cannot open the box. Opening it, and the vendor
|
||||||
// the vendor call that follows, happen where the manager node's private key is (ADR 0050, Phase C).
|
// call that follows, happen where the manager node's private key is (ADR 0050, Phase C).
|
||||||
type RefreshInput struct {
|
type RefreshInput struct {
|
||||||
Licence string
|
Licence string
|
||||||
// Manager is the node that holds the refresh token readably — the one place the envelope opens.
|
// Manager is the node that holds the refresh token readably — the one place the box opens.
|
||||||
Manager string
|
Manager string
|
||||||
// AtRest is the refresh token encrypted at rest under the manager's key. Opaque to the control
|
// Sealed is the refresh token as an anonymous sealed box to the manager's key. Opaque to the
|
||||||
// plane; meaningful only to the manager node that produced it.
|
// control plane; openable only by the manager node's private half.
|
||||||
AtRest secrets.AtRest
|
Sealed string
|
||||||
|
// ManagerKey is the manager's public sealing key the token was sealed to.
|
||||||
|
ManagerKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshResult is what a refresh produced: a new access token to seal per holder, and — only if the
|
// 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.
|
// vendor rotated it — the refresh token re-sealed to the manager, ready to replace the stored blob.
|
||||||
type RefreshResult struct {
|
type RefreshResult struct {
|
||||||
// AccessToken is the new access token, in the clear. The mesh seals it per holder and discards
|
// 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.
|
// it, exactly as it does an accepted key. It is never the refresh token.
|
||||||
AccessToken string
|
AccessToken string
|
||||||
// NewAtRest is the refresh token re-sealed at rest, present only when the vendor rotated the
|
// NewSealed is the refresh token re-sealed to the manager node, present only when the vendor
|
||||||
// refresh token too. Nil leaves the stored envelope untouched. Already encrypted, so the control
|
// rotated the refresh token too. Empty leaves the stored blob untouched. Already sealed, so the
|
||||||
// plane stores it without ever seeing the refresh token in the clear.
|
// control plane stores it without ever seeing the refresh token in the clear.
|
||||||
NewAtRest *secrets.AtRest
|
NewSealed string
|
||||||
|
// NewManagerKey is the key NewSealed was sealed to, carried with it.
|
||||||
|
NewManagerKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresher is implemented only by a refreshable-grant adapter (ADR 0050): the vendor-neutral half
|
// Refresher is implemented only by a refreshable-grant adapter (ADR 0050): the vendor-neutral half
|
||||||
@@ -229,11 +233,11 @@ func (s staticKey) Deliver(sealed string) string { return sealed }
|
|||||||
// vendor's actual OAuth call is the injected refresher.
|
// 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
|
// **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,
|
// this adapter. It lives in the licences context's own refresh_grant store, keyed by licence, sealed
|
||||||
// keyed by licence, encrypted to the manager node (secrets.AtRest). What Accept seals and Deliver
|
// to the manager node (secrets.Seal) and delivered to the manager holder alone. What Accept seals and
|
||||||
// hands out is the ACCESS token, per holder, exactly as a static key's value is — so "the refresh
|
// Deliver hands out to a CONSUMER is the ACCESS token, per holder, exactly as a static key's value is
|
||||||
// token is stripped on delivery" is structural here: there is nothing in a holder's row to strip,
|
// — so "a consumer is never delivered the refresh token" is structural here: a consumer's row never
|
||||||
// because the refresh token was never put there.
|
// holds it, because the refresh token is a different holder's credential entirely.
|
||||||
type refreshableGrant struct {
|
type refreshableGrant struct {
|
||||||
vendor string
|
vendor string
|
||||||
refresher VendorRefresher
|
refresher VendorRefresher
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/novox/mesh-control/internal/secrets"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTheTwoShapesAreSelectedByVendor(t *testing.T) {
|
func TestTheTwoShapesAreSelectedByVendor(t *testing.T) {
|
||||||
@@ -63,8 +61,8 @@ func (f *fakeVendor) Refresh(_ context.Context, in RefreshInput) (RefreshResult,
|
|||||||
return f.result, nil
|
return f.result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plugged-in refresher is dispatched to, and is handed the at-rest envelope (never a plaintext
|
// A plugged-in refresher is dispatched to, and is handed the sealed refresh token (never a plaintext
|
||||||
// refresh token) plus which node is the manager.
|
// one) plus which node is the manager.
|
||||||
func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) {
|
func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) {
|
||||||
fake := &fakeVendor{result: RefreshResult{AccessToken: "at-new"}}
|
fake := &fakeVendor{result: RefreshResult{AccessToken: "at-new"}}
|
||||||
RegisterRefresher("anthropic", fake)
|
RegisterRefresher("anthropic", fake)
|
||||||
@@ -74,7 +72,7 @@ func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) {
|
|||||||
r := grant.(Refresher)
|
r := grant.(Refresher)
|
||||||
in := RefreshInput{
|
in := RefreshInput{
|
||||||
Licence: "personal", Manager: "workstation",
|
Licence: "personal", Manager: "workstation",
|
||||||
AtRest: secrets.AtRest{Token: "tok", WrappedKey: "wk", ManagerKey: "mk"},
|
Sealed: "sealed-box", ManagerKey: "mk",
|
||||||
}
|
}
|
||||||
out, err := r.Refresh(context.Background(), in)
|
out, err := r.Refresh(context.Background(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,7 +81,7 @@ func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) {
|
|||||||
if out.AccessToken != "at-new" {
|
if out.AccessToken != "at-new" {
|
||||||
t.Fatalf("the dispatched result did not come back: %q", out.AccessToken)
|
t.Fatalf("the dispatched result did not come back: %q", out.AccessToken)
|
||||||
}
|
}
|
||||||
if fake.got.Manager != "workstation" || fake.got.AtRest.Token != "tok" {
|
if fake.got.Manager != "workstation" || fake.got.Sealed != "sealed-box" {
|
||||||
t.Fatalf("the refresher was handed the wrong input: %+v", fake.got)
|
t.Fatalf("the refresher was handed the wrong input: %+v", fake.got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+220
-60
@@ -206,8 +206,31 @@ func (l *Licences) Chosen(ctx context.Context, node, module string) (string, err
|
|||||||
// for today's vendors. An unregistered vendor is not consulted: a static-key blob delivers as it is,
|
// for today's vendors. An unregistered vendor is not consulted: a static-key blob delivers as it is,
|
||||||
// and a licence whose key was accepted at all necessarily had a registered adapter.
|
// and a licence whose key was accepted at all necessarily had a registered adapter.
|
||||||
func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (string, error) {
|
func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (string, error) {
|
||||||
|
// The manager holder is delivered the REFRESH token, not an access token: it is the one holder
|
||||||
|
// that refreshes rather than consumes (novox/hq ADR 0050). It is sealed to this same node's key
|
||||||
|
// with the very same anonymous box a consumer's credential is, so it rides the identical
|
||||||
|
// host-unseal-and-mount path — the host opens it, the manager module reads cleartext, and the
|
||||||
|
// module is never handed a private key. Nothing to strip on the consumer side and nothing bespoke
|
||||||
|
// on this one: the refresh token is simply the credential this particular holder receives.
|
||||||
|
managerNode, managerModule, err := l.ManagerOf(ctx, licence)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if managerNode != "" && node == managerNode && module == managerModule {
|
||||||
|
sealed, _, ok, err := l.RefreshGrant(ctx, licence)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// No refresh token adopted yet — empty, exactly as a consumer with no key. The
|
||||||
|
// declaration refuses that by name, where the module and path are both in view.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return sealed, nil
|
||||||
|
}
|
||||||
|
|
||||||
var sealed *string
|
var sealed *string
|
||||||
err := l.store.Pool().QueryRow(ctx,
|
err = l.store.Pool().QueryRow(ctx,
|
||||||
`select sealed from licence_holder where licence = $1 and node = $2 and module = $3`,
|
`select sealed from licence_holder where licence = $1 and node = $2 and module = $3`,
|
||||||
licence, node, module).Scan(&sealed)
|
licence, node, module).Scan(&sealed)
|
||||||
if errors.Is(err, pgx.ErrNoRows) || sealed == nil {
|
if errors.Is(err, pgx.ErrNoRows) || sealed == nil {
|
||||||
@@ -279,8 +302,18 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali
|
|||||||
"Put a consumer on it first, then supply the key", licence)
|
"Put a consumer on it first, then supply the key", licence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The manager holder is delivered the refresh token, not an operator-supplied access key — its
|
||||||
|
// row is fed by adoption and refresh, not by this. Skipped so an accepted value never clobbers it.
|
||||||
|
managerNode, managerModule, err := l.ManagerOf(ctx, licence)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
sealed := 0
|
sealed := 0
|
||||||
for _, h := range holders {
|
for _, h := range holders {
|
||||||
|
if managerNode != "" && h.Node == managerNode && h.Module == managerModule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key, err := keys(h.Node)
|
key, err := keys(h.Node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sealed, err
|
return sealed, err
|
||||||
@@ -305,35 +338,47 @@ 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.
|
// ManagerOf is the node and module that hold a licence's refresh token readably, both empty if none
|
||||||
|
// is named.
|
||||||
//
|
//
|
||||||
// Empty for every static-key licence, which has nothing to refresh, and for a refreshable-grant one
|
// 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).
|
// before its manager is set (novox/hq ADR 0050). The module is returned alongside the node because a
|
||||||
func (l *Licences) ManagerOf(ctx context.Context, licence string) (string, error) {
|
// node may run the manager module and a consuming module of the same licence at once, and which
|
||||||
var manager *string
|
// holder is delivered the refresh token turns on the module, not the node alone.
|
||||||
err := l.store.Pool().QueryRow(ctx,
|
func (l *Licences) ManagerOf(ctx context.Context, licence string) (node, module string, err error) {
|
||||||
`select manager from licence where name = $1`, licence).Scan(&manager)
|
var mgr, mod *string
|
||||||
|
err = l.store.Pool().QueryRow(ctx,
|
||||||
|
`select manager, manager_module from licence where name = $1`, licence).Scan(&mgr, &mod)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return "", fmt.Errorf("this mesh has no licence called %q", licence)
|
return "", "", fmt.Errorf("this mesh has no licence called %q", licence)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
if manager == nil {
|
if mgr == nil {
|
||||||
return "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
return *manager, nil
|
if mod == nil {
|
||||||
|
return *mgr, "", nil
|
||||||
|
}
|
||||||
|
return *mgr, *mod, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetManager names the one node that holds a licence's refresh token and refreshes it centrally.
|
// SetManager names the one node, and the module on it, that hold a licence's refresh token and
|
||||||
|
// refresh it centrally.
|
||||||
//
|
//
|
||||||
// **Only a refreshable-grant licence has one.** A static-key licence has no refresh token, so naming
|
// **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
|
// 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
|
// 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.
|
// "the manager module 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) == "" {
|
// **The module is named too, and it is the holder that is delivered the refresh token.** The manager
|
||||||
return errors.New("a manager needs a node")
|
// module must also be put on the licence as a holder (`Use`), so the plan resolves its model-access
|
||||||
|
// requirement; naming it here is what tells delivery to hand THAT holder the refresh token rather than
|
||||||
|
// an access token.
|
||||||
|
func (l *Licences) SetManager(ctx context.Context, licence, node, module string) error {
|
||||||
|
if strings.TrimSpace(node) == "" || strings.TrimSpace(module) == "" {
|
||||||
|
return errors.New("a manager needs a node and the module on it that refreshes")
|
||||||
}
|
}
|
||||||
vendor, err := l.vendorOf(ctx, licence)
|
vendor, err := l.vendorOf(ctx, licence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,7 +394,7 @@ func (l *Licences) SetManager(ctx context.Context, licence, node string) error {
|
|||||||
"has a refresh token to hold", licence, adapter.Shape())
|
"has a refresh token to hold", licence, adapter.Shape())
|
||||||
}
|
}
|
||||||
tag, err := l.store.Pool().Exec(ctx,
|
tag, err := l.store.Pool().Exec(ctx,
|
||||||
`update licence set manager = $2 where name = $1`, licence, node)
|
`update licence set manager = $2, manager_module = $3 where name = $1`, licence, node, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -359,42 +404,42 @@ func (l *Licences) SetManager(ctx context.Context, licence, node string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRefreshGrant stores, or replaces, a licence's refresh token as its at-rest envelope.
|
// SetRefreshGrant stores, or replaces, a licence's refresh token as one sealed blob.
|
||||||
//
|
//
|
||||||
// **The envelope is opaque here.** It was produced by the manager node — the only place the refresh
|
// **The blob is opaque here, and it is an ordinary sealed box.** It was produced where the refresh
|
||||||
// token is ever in the clear (novox/hq ADR 0050, Phase C) — and this context keeps it and forwards
|
// token was in the clear — the manager node, at adoption or after a rotation — sealed to that node's
|
||||||
// it to a refresh without opening it. The control plane holds no key that could, which is the whole
|
// public sealing key with the same `crypto_box_seal` every credential uses (novox/hq ADR 0050). This
|
||||||
// point of where the carve-out draws the line.
|
// context keeps it and delivers it without opening it: the control plane holds no private key that
|
||||||
func (l *Licences) SetRefreshGrant(ctx context.Context, licence string, at secrets.AtRest) error {
|
// could, which is the whole point of where the carve-out draws the line.
|
||||||
if at.Token == "" || at.WrappedKey == "" || at.ManagerKey == "" {
|
func (l *Licences) SetRefreshGrant(ctx context.Context, licence, sealed, managerKey string) error {
|
||||||
return errors.New("an incomplete refresh-token envelope is not one to keep")
|
if strings.TrimSpace(sealed) == "" || strings.TrimSpace(managerKey) == "" {
|
||||||
|
return errors.New("an incomplete refresh-token grant is not one to keep")
|
||||||
}
|
}
|
||||||
_, err := l.store.Pool().Exec(ctx,
|
_, err := l.store.Pool().Exec(ctx,
|
||||||
`insert into refresh_grant (licence, token, wrapped_key, manager_key)
|
`insert into refresh_grant (licence, sealed, manager_key)
|
||||||
values ($1, $2, $3, $4)
|
values ($1, $2, $3)
|
||||||
on conflict (licence) do update set
|
on conflict (licence) do update set
|
||||||
token = excluded.token, wrapped_key = excluded.wrapped_key,
|
sealed = excluded.sealed, manager_key = excluded.manager_key, updated_at = now()`,
|
||||||
manager_key = excluded.manager_key, updated_at = now()`,
|
licence, sealed, managerKey)
|
||||||
licence, at.Token, at.WrappedKey, at.ManagerKey)
|
|
||||||
if err != nil && strings.Contains(err.Error(), "refresh_grant_licence_fkey") {
|
if err != nil && strings.Contains(err.Error(), "refresh_grant_licence_fkey") {
|
||||||
return fmt.Errorf("this mesh has no licence called %q", licence)
|
return fmt.Errorf("this mesh has no licence called %q", licence)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshGrant is a licence's refresh token as its at-rest envelope, and whether one is stored.
|
// RefreshGrant is a licence's sealed refresh token, the key it was sealed to, and whether one is
|
||||||
func (l *Licences) RefreshGrant(ctx context.Context, licence string) (secrets.AtRest, bool, error) {
|
// stored.
|
||||||
var at secrets.AtRest
|
func (l *Licences) RefreshGrant(ctx context.Context, licence string) (sealed, managerKey string, ok bool, err error) {
|
||||||
err := l.store.Pool().QueryRow(ctx,
|
err = l.store.Pool().QueryRow(ctx,
|
||||||
`select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence).
|
`select sealed, manager_key from refresh_grant where licence = $1`, licence).
|
||||||
Scan(&at.Token, &at.WrappedKey, &at.ManagerKey)
|
Scan(&sealed, &managerKey)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return secrets.AtRest{}, false, nil
|
return "", "", false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return secrets.AtRest{}, false, err
|
return "", "", false, err
|
||||||
}
|
}
|
||||||
return at, true, nil
|
return sealed, managerKey, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh mints a new access token for a refreshable-grant licence, seals it to every holder, and
|
// Refresh mints a new access token for a refreshable-grant licence, seals it to every holder, and
|
||||||
@@ -459,10 +504,10 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
|
|||||||
" licence manager %s <node>", licence, licence)
|
" licence manager %s <node>", licence, licence)
|
||||||
}
|
}
|
||||||
|
|
||||||
var at secrets.AtRest
|
var sealedRefresh, managerKey string
|
||||||
err = tx.QueryRow(ctx,
|
err = tx.QueryRow(ctx,
|
||||||
`select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence).
|
`select sealed, manager_key from refresh_grant where licence = $1`, licence).
|
||||||
Scan(&at.Token, &at.WrappedKey, &at.ManagerKey)
|
Scan(&sealedRefresh, &managerKey)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"%q has no refresh token stored yet; its manager %s adopts one first "+
|
"%q has no refresh token stored yet; its manager %s adopts one first "+
|
||||||
@@ -473,7 +518,9 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := refresher.Refresh(ctx,
|
result, err := refresher.Refresh(ctx,
|
||||||
adapters.RefreshInput{Licence: licence, Manager: *manager, AtRest: at})
|
adapters.RefreshInput{
|
||||||
|
Licence: licence, Manager: *manager, Sealed: sealedRefresh, ManagerKey: managerKey,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -482,14 +529,132 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
|
|||||||
"the refresh produced no access token for %q, so nothing was resealed", licence)
|
"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
|
// The reseal-and-publish half, shared with SubmitRefresh: the new access token is sealed to every
|
||||||
// keep no readable copy.
|
// consumer holder that exists now, and a rotated refresh token replaces the stored sealed blob —
|
||||||
|
// never seen in the clear either way.
|
||||||
|
sealed, err := resealAndPublish(
|
||||||
|
ctx, tx, licence, result.AccessToken, result.NewSealed, result.NewManagerKey, keys)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return sealed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitRefresh takes a refresh a MANAGER NODE already performed and publishes it: it seals the new
|
||||||
|
// access token to every holder and replaces the stored refresh envelope if the vendor rotated it.
|
||||||
|
//
|
||||||
|
// **This is the entry point that keeps the control plane blind to the refresh token** (novox/hq ADR
|
||||||
|
// 0050, Phase C). `Refresh` above calls an in-process VendorRefresher — which would open the at-rest
|
||||||
|
// envelope inside the control plane's own process, exactly what the carve-out forbids. So Anthropic
|
||||||
|
// registers no in-process refresher; instead its manager runtime, on the manager node, opens the
|
||||||
|
// envelope with that node's own key, calls the vendor's OAuth endpoint, and submits the *result*
|
||||||
|
// here: the new access token in the clear (which the mesh seals per holder and discards, as it does
|
||||||
|
// any accepted key) and — only if the vendor rotated it — the refresh token already re-sealed at
|
||||||
|
// rest (which the mesh stores without ever opening). The refresh token in the clear never crosses
|
||||||
|
// this boundary, because this function is never given it.
|
||||||
|
//
|
||||||
|
// It reuses the same lease, the same reseal-and-publish, and the same all-or-nothing transaction as
|
||||||
|
// `Refresh`; the only difference is where the access token came from — a module on the manager node
|
||||||
|
// rather than a plug-in in this process.
|
||||||
|
func (l *Licences) SubmitRefresh(
|
||||||
|
ctx context.Context, licence, accessToken, newSealed, newManagerKey string, keys SealingKeys,
|
||||||
|
) (int, error) {
|
||||||
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"a refresh submitted for %q carried no access token, so there is nothing to seal", licence)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := l.store.Pool().Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||||
|
|
||||||
|
// The same lease Refresh takes: a submitted refresh and an in-process one serialise rather than
|
||||||
|
// racing to publish.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submitter must be a refreshable-grant licence with a manager named — the same gate Refresh
|
||||||
|
// applies, so a static key can never reach this machinery and a licence with no manager is not
|
||||||
|
// silently accepted from whoever called.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if adapter.Shape() != adapters.RefreshableGrant {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%q is a %s licence; only a refreshable-grant licence has a refresh to submit", licence,
|
||||||
|
adapter.Shape())
|
||||||
|
}
|
||||||
|
if manager == nil || *manager == "" {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%q has no manager named, so a refresh cannot be submitted for it. Name one:\n"+
|
||||||
|
" licence manager %s <node>", licence, licence)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, err := resealAndPublish(ctx, tx, licence, accessToken, newSealed, newManagerKey, keys)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return sealed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resealAndPublish seals a new access token to every CONSUMER holder and, if one is given, replaces
|
||||||
|
// the stored sealed refresh token with a rotated one. It is the half `Refresh` and `SubmitRefresh`
|
||||||
|
// share: the value's source differs, what is done with it does not.
|
||||||
|
//
|
||||||
|
// **The manager holder is skipped.** It is delivered the refresh token, not an access token (`KeyFor`);
|
||||||
|
// sealing an access token into its row would be a value nothing reads, and — worse — would overwrite
|
||||||
|
// the delivery bookkeeping for the one holder whose credential is the refresh token. So the reseal
|
||||||
|
// walks consumer holders only, and the count it returns is the number of consumers a push will carry
|
||||||
|
// the new access token to.
|
||||||
|
//
|
||||||
|
// The refresh token is never in the clear here — a rotated one arrives already sealed to the manager
|
||||||
|
// node, and is stored as the opaque blob it is. A consumer's `KeyFor` reads licence_holder, so it can
|
||||||
|
// only ever deliver an access token.
|
||||||
|
func resealAndPublish(
|
||||||
|
ctx context.Context, tx pgx.Tx, licence, accessToken, newSealed, newManagerKey string,
|
||||||
|
keys SealingKeys,
|
||||||
|
) (int, error) {
|
||||||
|
var managerNode, managerModule *string
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`select manager, manager_module from licence where name = $1`, licence).
|
||||||
|
Scan(&managerNode, &managerModule); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
holders, err := holdersTx(ctx, tx, licence)
|
holders, err := holdersTx(ctx, tx, licence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
sealed := 0
|
sealed := 0
|
||||||
for _, h := range holders {
|
for _, h := range holders {
|
||||||
|
if managerNode != nil && managerModule != nil &&
|
||||||
|
h.Node == *managerNode && h.Module == *managerModule {
|
||||||
|
// The manager holder receives the refresh token, not this access token. Left untouched.
|
||||||
|
continue
|
||||||
|
}
|
||||||
key, err := keys(h.Node)
|
key, err := keys(h.Node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -498,7 +663,7 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
|
|||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"%s has no sealing key, so the new access token cannot be sealed to it", h.Node)
|
"%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))
|
blob, err := secrets.Seal(key, []byte(accessToken))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -511,26 +676,21 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
|
|||||||
sealed++
|
sealed++
|
||||||
}
|
}
|
||||||
|
|
||||||
// The refresh token stays put unless the vendor rotated it, in which case the refresher returned
|
// The refresh token stays put unless the vendor rotated it, in which case the manager sealed the
|
||||||
// it already re-encrypted at rest — replaced here without ever being seen in the clear.
|
// new one to its own node key before submitting — replaced here without ever being seen in the
|
||||||
if result.NewAtRest != nil {
|
// clear.
|
||||||
if result.NewAtRest.Token == "" || result.NewAtRest.WrappedKey == "" ||
|
if newSealed != "" {
|
||||||
result.NewAtRest.ManagerKey == "" {
|
if newManagerKey == "" {
|
||||||
return 0, fmt.Errorf(
|
return 0, fmt.Errorf(
|
||||||
"the refresh returned an incomplete re-sealed refresh token for %q", licence)
|
"the refresh returned a re-sealed refresh token for %q with no manager key", licence)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`update refresh_grant set token = $2, wrapped_key = $3, manager_key = $4, updated_at = now()
|
`update refresh_grant set sealed = $2, manager_key = $3, updated_at = now()
|
||||||
where licence = $1`,
|
where licence = $1`,
|
||||||
licence, result.NewAtRest.Token, result.NewAtRest.WrappedKey,
|
licence, newSealed, newManagerKey); err != nil {
|
||||||
result.NewAtRest.ManagerKey); err != nil {
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return sealed, nil
|
return sealed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ create table licence (
|
|||||||
-- A name, not a foreign key: nodes live in another context this one may not join across
|
-- A name, not a foreign key: nodes live in another context this one may not join across
|
||||||
-- (novox/hq ADR 0008).
|
-- (novox/hq ADR 0008).
|
||||||
manager text,
|
manager text,
|
||||||
|
-- The module ON the manager node that runs the refresh -- the manager holder. Named alongside
|
||||||
|
-- the manager node because a node may run the manager module AND a consuming module of the same
|
||||||
|
-- licence (the lab co-locates both), and which holder is delivered the refresh token rather than
|
||||||
|
-- an access token turns on the module, not the node alone. Null exactly when `manager` is.
|
||||||
|
manager_module text,
|
||||||
added_at timestamptz not null default now()
|
added_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,29 +53,33 @@ create table licence_holder (
|
|||||||
primary key (licence, node, module)
|
primary key (licence, node, module)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- The refresh token, encrypted at rest under the manager node's key.
|
-- The refresh token, sealed to the manager node's key -- the same anonymous box every credential
|
||||||
|
-- the mesh delivers uses.
|
||||||
--
|
--
|
||||||
-- **novox/hq ADR 0050's carve-out, and its one home.** A `refreshable-grant` licence cannot be both
|
-- **novox/hq ADR 0050's carve-out, delivered the way the mesh delivers everything else.** A
|
||||||
-- sealed so the mesh cannot read it and rotated centrally, because rotating means a node reads the
|
-- `refreshable-grant` licence cannot be both sealed so the mesh cannot read it and rotated centrally,
|
||||||
-- refresh token back. So exactly one node -- the licence's manager -- holds it readably, and it is
|
-- because rotating means a node reads the refresh token back. So exactly one node -- the licence's
|
||||||
-- kept here as an envelope only that node can open: a symmetric data key encrypts the token
|
-- manager -- reads it. But the earlier attempt at a bespoke at-rest envelope, and the module holding
|
||||||
-- (secretbox), and the data key is sealed to the manager's public key. This database on its own
|
-- the node's private key to open it, hit a wall the mesh's own design forbids: a module is never
|
||||||
-- holds ciphertext and a wrapped key with no private half to open either (novox/hq ADR 0004).
|
-- given a node's private sealing key. So the refresh token rides the *ordinary* path instead -- it is
|
||||||
|
-- an anonymous sealed box (secrets.Seal, `crypto_box_seal`) to the manager node's public sealing key,
|
||||||
|
-- exactly like a consumer's db password, and the HOST unseals it and mounts the cleartext at the
|
||||||
|
-- manager module's bound path. This database on its own holds a sealed box with no private half to
|
||||||
|
-- open it (novox/hq ADR 0004), the same guarantee as every other sealed value here.
|
||||||
--
|
--
|
||||||
-- **Separate from the access tokens.** licence_holder.sealed is the ACCESS token, sealed per holder
|
-- **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
|
-- and delivered to consumers. This is the REFRESH token, one per licence, delivered to the manager
|
||||||
-- them in different tables is what makes "the refresh token is stripped on delivery" structural:
|
-- holder alone. Keeping them apart is what makes "a consumer is never delivered the refresh token"
|
||||||
-- delivery reads licence_holder, and the refresh token is not in it.
|
-- structural: a consumer's delivery reads licence_holder, and the refresh token is not there.
|
||||||
create table refresh_grant (
|
create table refresh_grant (
|
||||||
-- One refresh token per licence. On delete cascade: forgetting a licence forgets its refresh
|
-- One refresh token per licence. On delete cascade: forgetting a licence forgets its refresh
|
||||||
-- token with it, the same way it forgets its holders.
|
-- token with it, the same way it forgets its holders.
|
||||||
licence text primary key references licence(name) on delete cascade,
|
licence text primary key references licence(name) on delete cascade,
|
||||||
|
|
||||||
-- base64( nonce || secretbox(data_key, refresh_token) ) -- the token under the symmetric key.
|
-- base64( anonymous-box( manager sealing key, refresh_token ) ) -- the refresh token sealed to
|
||||||
token text not null,
|
-- the manager node, openable only by that node's private half, which the mesh never holds.
|
||||||
-- base64( anonymous-box(manager_key, data_key) ) -- the data key closed to the manager node.
|
sealed text not null,
|
||||||
wrapped_key text not null,
|
-- The manager's public sealing key the refresh token was sealed to. Kept so a manager that
|
||||||
-- 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
|
-- 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).
|
-- refresh that will not decrypt (the same reason licence_holder.node_key is kept).
|
||||||
manager_key text not null,
|
manager_key text not null,
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
-- A manager, and the refresh token it holds encrypted at rest.
|
-- A manager, and the refresh token it holds -- sealed to that node, the way every credential is.
|
||||||
--
|
--
|
||||||
-- novox/hq ADR 0050, Phase B. The consolidated schema (0001) now creates the `manager` column and
|
-- novox/hq ADR 0050. The consolidated schema (0001) creates the `manager` and `manager_module`
|
||||||
-- the `refresh_grant` table; this migration carries an existing database the same distance, so a
|
-- columns and the `refresh_grant` table in its final shape; this migration carries an existing
|
||||||
-- database that predates the carve-out gains exactly what a fresh one is created with.
|
-- 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
|
-- **Idempotent, and it converges rather than assumes.** An early cut of this carve-out kept the
|
||||||
-- already has both, and re-adding them would fail; `if not exists` on both makes the two paths --
|
-- refresh token as a bespoke at-rest envelope (`token` + `wrapped_key`) so the manager MODULE could
|
||||||
-- fresh and pre-existing -- end at the same schema.
|
-- open it with the node's private key. That was retired before release: a module is never given a
|
||||||
|
-- node's private sealing key, so the refresh token now rides the ordinary sealed-delivery path --
|
||||||
|
-- one anonymous sealed box to the manager node's public key, unsealed by the HOST. This migration
|
||||||
|
-- therefore also drops those columns and adds `sealed` for any database that ran the earlier shape,
|
||||||
|
-- so both a pristine database and one carried through the early cut end at the same schema.
|
||||||
|
|
||||||
alter table licence add column if not exists manager text;
|
alter table licence add column if not exists manager text;
|
||||||
|
alter table licence add column if not exists manager_module text;
|
||||||
|
|
||||||
create table if not exists refresh_grant (
|
create table if not exists refresh_grant (
|
||||||
licence text primary key references licence(name) on delete cascade,
|
licence text primary key references licence(name) on delete cascade,
|
||||||
token text not null,
|
sealed text not null,
|
||||||
wrapped_key text not null,
|
|
||||||
manager_key text not null,
|
manager_key text not null,
|
||||||
updated_at timestamptz not null default now()
|
updated_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Converge a database that created refresh_grant in the retired at-rest shape. There is nothing to
|
||||||
|
-- preserve: an unreleased carve-out held no production refresh tokens, and a refresh token cannot be
|
||||||
|
-- re-derived from a wrapped envelope this migration cannot open. The manager re-adopts.
|
||||||
|
alter table refresh_grant add column if not exists sealed text;
|
||||||
|
alter table refresh_grant drop column if exists token;
|
||||||
|
alter table refresh_grant drop column if exists wrapped_key;
|
||||||
|
update refresh_grant set sealed = '' where sealed is null;
|
||||||
|
alter table refresh_grant alter column sealed set not null;
|
||||||
|
|||||||
@@ -39,33 +39,56 @@ func nodeKeyPair(t *testing.T) (public string, open func(string) ([]byte, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// managerPair is like nodeKeyPair but also returns the private key string, because the manager needs
|
// managerPair is like nodeKeyPair but returns the private key string too, because the MANAGER opens
|
||||||
// to OpenAtRest its own refresh token — the one node that reads it back.
|
// its own refresh token — the one node that reads it back — and the host on that node does so with
|
||||||
func managerPair(t *testing.T) (public, private string) {
|
// box.OpenAnonymous, exactly as it opens any sealed credential.
|
||||||
|
func managerPair(t *testing.T) (public, private string, open func(string) ([]byte, error)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
var pub, sk [32]byte
|
||||||
|
copy(pub[:], priv.PublicKey().Bytes())
|
||||||
|
copy(sk[:], priv.Bytes())
|
||||||
return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()),
|
return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()),
|
||||||
base64.StdEncoding.EncodeToString(priv.Bytes())
|
base64.StdEncoding.EncodeToString(priv.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
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeRefresher struct {
|
type fakeRefresher struct {
|
||||||
access string
|
access string
|
||||||
newAtRest *secrets.AtRest
|
newSealed string
|
||||||
got adapters.RefreshInput
|
newManagerKey string
|
||||||
|
got adapters.RefreshInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRefresher) Refresh(_ context.Context, in adapters.RefreshInput) (adapters.RefreshResult, error) {
|
func (f *fakeRefresher) Refresh(_ context.Context, in adapters.RefreshInput) (adapters.RefreshResult, error) {
|
||||||
f.got = in
|
f.got = in
|
||||||
return adapters.RefreshResult{AccessToken: f.access, NewAtRest: f.newAtRest}, nil
|
return adapters.RefreshResult{
|
||||||
|
AccessToken: f.access, NewSealed: f.newSealed, NewManagerKey: f.newManagerKey,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// A refreshable-grant licence set up end to end: a manager, a refresh token sealed at rest to it, two
|
// A refreshable-grant licence set up end to end: a manager node running the manager module (a holder
|
||||||
// holders each with a sealing key, and a fake vendor refresher plugged in.
|
// delivered the refresh token), the refresh token sealed to it, two CONSUMER holders — one of them on
|
||||||
|
// the manager node itself, to exercise co-location — and a fake vendor refresher plugged in.
|
||||||
|
//
|
||||||
|
// The manager node is "workstation" and its manager module is "manager"; the consuming module is
|
||||||
|
// "assistant", present on both "workstation" and "laptop".
|
||||||
func aRefreshableLicence(t *testing.T, held *Licences, ctx context.Context, fake *fakeRefresher) (
|
func aRefreshableLicence(t *testing.T, held *Licences, ctx context.Context, fake *fakeRefresher) (
|
||||||
managerPub, managerPriv string, holders map[string]func(string) ([]byte, error), keys SealingKeys,
|
managerPub, managerPriv string, managerOpen func(string) ([]byte, error),
|
||||||
|
holders map[string]func(string) ([]byte, error), keys SealingKeys,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
adapters.RegisterRefresher("anthropic", fake)
|
adapters.RegisterRefresher("anthropic", fake)
|
||||||
@@ -74,45 +97,52 @@ func aRefreshableLicence(t *testing.T, held *Licences, ctx context.Context, fake
|
|||||||
if err := held.Add(ctx, "personal", "anthropic", map[string]any{"model": "a-model"}); err != nil {
|
if err := held.Add(ctx, "personal", "anthropic", map[string]any{"model": "a-model"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := held.SetManager(ctx, "personal", "workstation"); err != nil {
|
if err := held.SetManager(ctx, "personal", "workstation", "manager"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
managerPub, managerPriv = managerPair(t)
|
managerPub, managerPriv, managerOpen = managerPair(t)
|
||||||
at, err := secrets.SealAtRest("rt-the-refresh-token", managerPub)
|
sealedRefresh, err := secrets.Seal(managerPub, []byte("rt-the-refresh-token"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := held.SetRefreshGrant(ctx, "personal", at); err != nil {
|
if err := held.SetRefreshGrant(ctx, "personal", sealedRefresh, managerPub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The manager module is a holder too, on the manager node, so resealAndPublish has it to skip.
|
||||||
|
if err := held.Use(ctx, "personal", "workstation", "manager"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
holders = map[string]func(string) ([]byte, error){}
|
holders = map[string]func(string) ([]byte, error){}
|
||||||
pub := map[string]string{}
|
pub := map[string]string{"workstation": managerPub}
|
||||||
|
_, holders["workstation"] = "", managerOpen // consumer on the manager node shares its key
|
||||||
for _, node := range []string{"workstation", "laptop"} {
|
for _, node := range []string{"workstation", "laptop"} {
|
||||||
p, open := nodeKeyPair(t)
|
if node == "laptop" {
|
||||||
pub[node], holders[node] = p, open
|
p, open := nodeKeyPair(t)
|
||||||
|
pub[node], holders[node] = p, open
|
||||||
|
}
|
||||||
if err := held.Use(ctx, "personal", node, "assistant"); err != nil {
|
if err := held.Use(ctx, "personal", node, "assistant"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keys = func(node string) (string, error) { return pub[node], nil }
|
keys = func(node string) (string, error) { return pub[node], nil }
|
||||||
return managerPub, managerPriv, holders, keys
|
return managerPub, managerPriv, managerOpen, holders, keys
|
||||||
}
|
}
|
||||||
|
|
||||||
// The point of the phase, in one test: a refresh seals the ACCESS token to every holder, and the
|
// The point of the phase, in one test: a refresh seals the ACCESS token to every CONSUMER holder, the
|
||||||
// refresh token is nowhere a holder can reach it.
|
// manager holder is delivered the refresh token, and the refresh token is nowhere a consumer reaches.
|
||||||
func TestARefreshDeliversTheAccessTokenAndNeverTheRefreshToken(t *testing.T) {
|
func TestARefreshDeliversTheAccessTokenAndNeverTheRefreshToken(t *testing.T) {
|
||||||
held, ctx := fresh(t)
|
held, ctx := fresh(t)
|
||||||
fake := &fakeRefresher{access: "at-brand-new-access-token"}
|
fake := &fakeRefresher{access: "at-brand-new-access-token"}
|
||||||
_, _, holders, keys := aRefreshableLicence(t, held, ctx, fake)
|
_, _, managerOpen, holders, keys := aRefreshableLicence(t, held, ctx, fake)
|
||||||
|
|
||||||
sealed, err := held.Refresh(ctx, "personal", keys)
|
sealed, err := held.Refresh(ctx, "personal", keys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if sealed != 2 {
|
if sealed != 2 {
|
||||||
t.Fatalf("%d holder(s) were resealed, expected 2", sealed)
|
t.Fatalf("%d consumer holder(s) were resealed, expected 2", sealed)
|
||||||
}
|
}
|
||||||
|
|
||||||
for node, open := range holders {
|
for node, open := range holders {
|
||||||
@@ -130,26 +160,38 @@ func TestARefreshDeliversTheAccessTokenAndNeverTheRefreshToken(t *testing.T) {
|
|||||||
if string(got) != "at-brand-new-access-token" {
|
if string(got) != "at-brand-new-access-token" {
|
||||||
t.Fatalf("%s was delivered %q, not the access token", node, got)
|
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") {
|
if string(got) == "rt-the-refresh-token" || strings.Contains(blob, "rt-the-refresh-token") {
|
||||||
t.Fatalf("%s was delivered the refresh token", node)
|
t.Fatalf("%s was delivered the refresh token", node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The manager holder is delivered the refresh token, and opens it with the node's own key.
|
||||||
|
mgrBlob, err := held.KeyFor(ctx, "personal", "workstation", "manager")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := managerOpen(mgrBlob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("the manager cannot open the refresh token delivered to it")
|
||||||
|
}
|
||||||
|
if string(got) != "rt-the-refresh-token" {
|
||||||
|
t.Fatalf("the manager was delivered %q, not the refresh token", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyFor delivers the access token and cannot deliver the refresh token, because the refresh token
|
// A CONSUMER holder is never delivered the refresh token, because a consumer's row never holds it and
|
||||||
// is not in licence_holder at all — the stripping is structural.
|
// KeyFor for a consumer reads licence_holder — the separation is structural.
|
||||||
func TestKeyForNeverCarriesTheRefreshToken(t *testing.T) {
|
func TestKeyForNeverCarriesTheRefreshTokenToAConsumer(t *testing.T) {
|
||||||
held, ctx := fresh(t)
|
held, ctx := fresh(t)
|
||||||
fake := &fakeRefresher{access: "at-access"}
|
fake := &fakeRefresher{access: "at-access"}
|
||||||
_, _, _, keys := aRefreshableLicence(t, held, ctx, fake)
|
_, _, _, _, keys := aRefreshableLicence(t, held, ctx, fake)
|
||||||
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every holder row, straight from the store: none of them holds the refresh token in any form.
|
// Every CONSUMER holder row, straight from the store: none holds the refresh token in any form.
|
||||||
rows, err := held.store.Pool().Query(ctx,
|
rows, err := held.store.Pool().Query(ctx,
|
||||||
`select coalesce(sealed, '') from licence_holder where licence = 'personal'`)
|
`select coalesce(sealed, '') from licence_holder where licence = 'personal' and module = 'assistant'`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -160,57 +202,51 @@ func TestKeyForNeverCarriesTheRefreshToken(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Contains(sealed, "rt-the-refresh-token") {
|
if strings.Contains(sealed, "rt-the-refresh-token") {
|
||||||
t.Fatal("a holder row carries the refresh token")
|
t.Fatal("a consumer holder row carries the refresh token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The refresh token at rest is not readable from the database alone: the row holds ciphertext and a
|
// The refresh token is not readable from the database alone: the row holds a sealed box, and only the
|
||||||
// wrapped key, and only the manager node's private half opens it.
|
// manager node's private half opens it — the same guarantee every sealed credential here has.
|
||||||
func TestTheRefreshTokenAtRestNeedsTheManagersKey(t *testing.T) {
|
func TestTheRefreshTokenNeedsTheManagersKey(t *testing.T) {
|
||||||
held, ctx := fresh(t)
|
held, ctx := fresh(t)
|
||||||
fake := &fakeRefresher{access: "at-access"}
|
fake := &fakeRefresher{access: "at-access"}
|
||||||
managerPub, managerPriv, _, _ := aRefreshableLicence(t, held, ctx, fake)
|
managerPub, managerPriv, _, _, _ := aRefreshableLicence(t, held, ctx, fake)
|
||||||
|
|
||||||
// What the database holds, read straight from the row.
|
var sealed, managerKey string
|
||||||
var token, wrapped, managerKey string
|
|
||||||
if err := held.store.Pool().QueryRow(ctx,
|
if err := held.store.Pool().QueryRow(ctx,
|
||||||
`select token, wrapped_key, manager_key from refresh_grant where licence = 'personal'`).
|
`select sealed, manager_key from refresh_grant where licence = 'personal'`).
|
||||||
Scan(&token, &wrapped, &managerKey); err != nil {
|
Scan(&sealed, &managerKey); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Contains(token, "rt-the-refresh-token") || strings.Contains(wrapped, "rt-the-refresh-token") {
|
if strings.Contains(sealed, "rt-the-refresh-token") {
|
||||||
t.Fatal("the refresh token is in the row in the clear")
|
t.Fatal("the refresh token is in the row in the clear")
|
||||||
}
|
}
|
||||||
|
if managerKey != managerPub {
|
||||||
// The manager, holding its private key, reads it back.
|
t.Fatal("the stored manager key is not the manager's public key")
|
||||||
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" {
|
|
||||||
|
// The manager, holding its private key, reads it back with box.OpenAnonymous (as the host does).
|
||||||
|
got := openAnon(t, sealed, managerPub, managerPriv)
|
||||||
|
if string(got) != "rt-the-refresh-token" {
|
||||||
t.Fatalf("the manager read back %q", got)
|
t.Fatalf("the manager read back %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Another node cannot, which is the whole of "the manager node only".
|
// Another node cannot, which is the whole of "the manager node only".
|
||||||
otherPub, otherPriv := managerPair(t)
|
otherPub, otherPriv, _ := managerPair(t)
|
||||||
if _, err := secrets.OpenAtRest(
|
if _, ok := tryOpenAnon(sealed, otherPub, otherPriv); ok {
|
||||||
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")
|
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
|
// After a refresh, consumers hold a NEW access token, and the refresh token that was not rotated is
|
||||||
// unchanged — never delivered either way.
|
// unchanged — never delivered to a consumer either way.
|
||||||
func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing.T) {
|
func TestAfterRefreshConsumersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing.T) {
|
||||||
held, ctx := fresh(t)
|
held, ctx := fresh(t)
|
||||||
fake := &fakeRefresher{access: "at-first"}
|
fake := &fakeRefresher{access: "at-first"}
|
||||||
_, _, holders, keys := aRefreshableLicence(t, held, ctx, fake)
|
_, _, _, 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 {
|
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +260,6 @@ func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing
|
|||||||
}
|
}
|
||||||
grantBefore := grantRow(t, held, ctx)
|
grantBefore := grantRow(t, held, ctx)
|
||||||
|
|
||||||
// A second refresh with a different access token and no rotation of the refresh token.
|
|
||||||
fake.access = "at-second"
|
fake.access = "at-second"
|
||||||
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -250,36 +285,36 @@ func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the vendor rotates the refresh token too, the stored envelope is replaced with the
|
// When the vendor rotates the refresh token too, the stored sealed box is replaced with the re-sealed
|
||||||
// re-encrypted one — and it is still not deliverable to a holder.
|
// one — and it is still delivered only to the manager, opening only with the manager's key.
|
||||||
func TestARotatedRefreshTokenReplacesTheStoredEnvelope(t *testing.T) {
|
func TestARotatedRefreshTokenReplacesTheStoredBox(t *testing.T) {
|
||||||
held, ctx := fresh(t)
|
held, ctx := fresh(t)
|
||||||
fake := &fakeRefresher{access: "at-access"}
|
fake := &fakeRefresher{access: "at-access"}
|
||||||
managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, fake)
|
managerPub, managerPriv, _, _, keys := aRefreshableLicence(t, held, ctx, fake)
|
||||||
|
|
||||||
grantBefore := grantRow(t, held, ctx)
|
grantBefore := grantRow(t, held, ctx)
|
||||||
|
|
||||||
rotated, err := secrets.SealAtRest("rt-a-rotated-refresh-token", managerPub)
|
rotated, err := secrets.Seal(managerPub, []byte("rt-a-rotated-refresh-token"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fake.newAtRest = &rotated
|
fake.newSealed, fake.newManagerKey = rotated, managerPub
|
||||||
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
if _, err := held.Refresh(ctx, "personal", keys); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if grantRow(t, held, ctx) == grantBefore {
|
if grantRow(t, held, ctx) == grantBefore {
|
||||||
t.Fatal("the rotated refresh token did not replace the stored envelope")
|
t.Fatal("the rotated refresh token did not replace the stored box")
|
||||||
}
|
}
|
||||||
at, ok, err := held.RefreshGrant(ctx, "personal")
|
sealed, key, ok, err := held.RefreshGrant(ctx, "personal")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err)
|
t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
got, err := secrets.OpenAtRest(at, managerPub, managerPriv)
|
if key != managerPub {
|
||||||
if err != nil {
|
t.Fatal("the rotated grant is not sealed to the manager's key")
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if got != "rt-a-rotated-refresh-token" {
|
got := openAnon(t, sealed, managerPub, managerPriv)
|
||||||
|
if string(got) != "rt-a-rotated-refresh-token" {
|
||||||
t.Fatalf("the stored grant opened to %q, not the rotated token", got)
|
t.Fatalf("the stored grant opened to %q, not the rotated token", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,10 +326,10 @@ func TestAStaticKeyLicenceHasNoManagerAndNoRefresh(t *testing.T) {
|
|||||||
if err := held.Add(ctx, "plain", "anthropic-api-key", nil); err != nil {
|
if err := held.Add(ctx, "plain", "anthropic-api-key", nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := held.SetManager(ctx, "plain", "workstation"); err == nil {
|
if err := held.SetManager(ctx, "plain", "workstation", "manager"); err == nil {
|
||||||
t.Fatal("a static-key licence was given a manager")
|
t.Fatal("a static-key licence was given a manager")
|
||||||
}
|
}
|
||||||
if _, ok, err := held.RefreshGrant(ctx, "plain"); err != nil || ok {
|
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)
|
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 {
|
if _, err := held.Refresh(ctx, "plain", func(string) (string, error) { return "", nil }); err == nil {
|
||||||
@@ -317,15 +352,38 @@ func TestARefreshableLicenceWithoutAManagerIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// grantRow is the whole at-rest envelope as one string, for asserting it changed or did not.
|
// grantRow is the whole sealed grant as one string, for asserting it changed or did not.
|
||||||
func grantRow(t *testing.T, held *Licences, ctx context.Context) string {
|
func grantRow(t *testing.T, held *Licences, ctx context.Context) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
at, ok, err := held.RefreshGrant(ctx, "personal")
|
sealed, key, ok, err := held.RefreshGrant(ctx, "personal")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return at.Token + "|" + at.WrappedKey + "|" + at.ManagerKey
|
return sealed + "|" + key
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAnon opens an anonymous sealed box with a node's key pair — the host's Unseal, inlined for a test.
|
||||||
|
func openAnon(t *testing.T, sealed, pubB64, privB64 string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
out, ok := tryOpenAnon(sealed, pubB64, privB64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("box.OpenAnonymous failed for a value that should open")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryOpenAnon(sealed, pubB64, privB64 string) ([]byte, bool) {
|
||||||
|
blob, err := base64.StdEncoding.DecodeString(sealed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
pubRaw, _ := base64.StdEncoding.DecodeString(pubB64)
|
||||||
|
privRaw, _ := base64.StdEncoding.DecodeString(privB64)
|
||||||
|
var pub, priv [32]byte
|
||||||
|
copy(pub[:], pubRaw)
|
||||||
|
copy(priv[:], privRaw)
|
||||||
|
return box.OpenAnonymous(nil, blob, &pub, &priv)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package licences
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-control/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubmitRefresh is the boundary a manager NODE crosses to publish a refresh it performed: the control
|
||||||
|
// plane is given only the access token in the clear and an opaque re-sealed refresh box — never the
|
||||||
|
// refresh token. These tests defend that the boundary keeps its shape.
|
||||||
|
|
||||||
|
// A submitted refresh seals the access token to every CONSUMER holder, exactly as an in-process
|
||||||
|
// refresh does, and delivers no refresh token to a consumer.
|
||||||
|
func TestSubmitRefreshSealsTheAccessTokenAndNeverTheRefreshToken(t *testing.T) {
|
||||||
|
held, ctx := fresh(t)
|
||||||
|
// No in-process refresher registered: the anthropic production path uses SubmitRefresh, not
|
||||||
|
// Refresh, precisely so nothing opens the box inside this process.
|
||||||
|
_, _, _, holders, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
|
||||||
|
|
||||||
|
sealed, err := held.SubmitRefresh(ctx, "personal", "at-from-the-manager-node", "", "", keys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sealed != 2 {
|
||||||
|
t.Fatalf("%d consumer 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)
|
||||||
|
}
|
||||||
|
got, err := open(blob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s cannot open what it was delivered", node)
|
||||||
|
}
|
||||||
|
if string(got) != "at-from-the-manager-node" {
|
||||||
|
t.Fatalf("%s was delivered %q, not the access token", node, got)
|
||||||
|
}
|
||||||
|
if string(got) == "rt-the-refresh-token" || strings.Contains(blob, "rt-the-refresh-token") {
|
||||||
|
t.Fatalf("%s was delivered the refresh token", node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refresh token is untouched by a submit that carried no rotation, and the manager node — and
|
||||||
|
// only it — still opens it. The submit path never saw the refresh token in the clear.
|
||||||
|
func TestSubmitRefreshWithoutRotationLeavesTheGrantOpenableByTheManagerAlone(t *testing.T) {
|
||||||
|
held, ctx := fresh(t)
|
||||||
|
managerPub, managerPriv, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
|
||||||
|
|
||||||
|
before := grantRow(t, held, ctx)
|
||||||
|
if _, err := held.SubmitRefresh(ctx, "personal", "at-access", "", "", keys); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if grantRow(t, held, ctx) != before {
|
||||||
|
t.Fatal("a submit with no rotation changed the stored refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, _, ok, err := held.RefreshGrant(ctx, "personal")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("the grant is not stored: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
got := openAnon(t, sealed, managerPub, managerPriv)
|
||||||
|
if string(got) != "rt-the-refresh-token" {
|
||||||
|
t.Fatalf("the manager read back %q", got)
|
||||||
|
}
|
||||||
|
// A node that is not the manager cannot: the whole of "the manager node only".
|
||||||
|
otherPub, otherPriv, _ := managerPair(t)
|
||||||
|
if _, ok := tryOpenAnon(sealed, otherPub, otherPriv); ok {
|
||||||
|
t.Fatal("a node that is not the manager opened the refresh token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A submit that carries a rotated box replaces the stored one — and the control plane stored it
|
||||||
|
// without opening it: only the manager node reads the rotated token back.
|
||||||
|
func TestSubmitRefreshWithRotationReplacesTheBoxUnopened(t *testing.T) {
|
||||||
|
held, ctx := fresh(t)
|
||||||
|
managerPub, managerPriv, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
|
||||||
|
|
||||||
|
before := grantRow(t, held, ctx)
|
||||||
|
|
||||||
|
// The manager node re-sealed the rotated refresh token to its own key; the control plane is handed
|
||||||
|
// only this box.
|
||||||
|
rotated, err := secrets.Seal(managerPub, []byte("rt-a-rotated-refresh-token"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := held.SubmitRefresh(ctx, "personal", "at-access", rotated, managerPub, keys); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if grantRow(t, held, ctx) == before {
|
||||||
|
t.Fatal("the rotated refresh token did not replace the stored box")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, _, 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 := openAnon(t, sealed, managerPub, managerPriv)
|
||||||
|
if string(got) != "rt-a-rotated-refresh-token" {
|
||||||
|
t.Fatalf("the stored grant opened to %q, not the rotated token", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A submit with an empty access token is refused before anything is sealed: publishing nothing while
|
||||||
|
// reporting success is the failure this whole design refuses.
|
||||||
|
func TestSubmitRefreshRefusesAnEmptyAccessToken(t *testing.T) {
|
||||||
|
held, ctx := fresh(t)
|
||||||
|
_, _, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
|
||||||
|
if _, err := held.SubmitRefresh(ctx, "personal", " ", "", "", keys); err == nil {
|
||||||
|
t.Fatal("a refresh with no access token was published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A static-key licence cannot have a refresh submitted for it: the carve-out never fires, so the
|
||||||
|
// machinery that holds a token readably is unreachable.
|
||||||
|
func TestSubmitRefreshRefusesAStaticKeyLicence(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.Use(ctx, "plain", "workstation", "assistant"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := held.SubmitRefresh(ctx, "plain", "at-access", "", "",
|
||||||
|
func(string) (string, error) { return ASealingKey(t), nil })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a refresh was submitted for a static-key licence")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refreshable-grant") {
|
||||||
|
t.Fatalf("the refusal does not name the shape: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refreshable licence with no manager named refuses a submit and says how to name one: a refresh
|
||||||
|
// cannot be published for a licence no node is responsible for.
|
||||||
|
func TestSubmitRefreshRefusesWithoutAManager(t *testing.T) {
|
||||||
|
held, ctx := fresh(t)
|
||||||
|
if err := held.Add(ctx, "personal", "anthropic", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := held.SubmitRefresh(ctx, "personal", "at-access", "", "",
|
||||||
|
func(string) (string, error) { return "", nil })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a refresh was submitted for a licence with no manager")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "manager") {
|
||||||
|
t.Fatalf("the refusal does not point at the missing manager: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The manager module's TypeScript seal and this package's Go seal are the SAME anonymous sealed box,
|
||||||
|
// byte for byte — the property the refreshable-grant carve-out rests on (novox/hq ADR 0050).
|
||||||
|
//
|
||||||
|
// **Why it must hold.** The refresh token is sealed to the manager node — at adoption and after each
|
||||||
|
// rotation — by the manager MODULE, in TypeScript (mesh-catalog anthropic-manager/sealedbox.ts). The
|
||||||
|
// HOST then unseals it with Go's box.OpenAnonymous (mesh-host identity.SealingKey.Unseal) to mount the
|
||||||
|
// cleartext, and mesh-control seals every other credential with box.SealAnonymous (secrets.Seal). If
|
||||||
|
// the TS seal and the Go box disagreed by a byte, the host would refuse the refresh token as a value
|
||||||
|
// it cannot open — silently, as a manager that never gets its credential. So this is load-bearing, and
|
||||||
|
// it is pinned here rather than trusted.
|
||||||
|
//
|
||||||
|
// The fixture is produced by the module's own compiled seal() over a fresh node key pair; this test
|
||||||
|
// opens it with box.OpenAnonymous — exactly what the host runs — and with secrets.Open, and recovers
|
||||||
|
// the plaintext. Regenerate it with the module's seal() if the construction ever changes; a drift
|
||||||
|
// shows up here as a fixture Go cannot open, which is the whole point.
|
||||||
|
func TestModuleSealedBoxOpensInGo(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/module-sealedbox-fixture.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var f struct {
|
||||||
|
ManagerPublicKey string `json:"managerPublicKey"`
|
||||||
|
ManagerPrivateKey string `json:"managerPrivateKey"`
|
||||||
|
Plaintext string `json:"plaintext"`
|
||||||
|
Sealed string `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, err := base64.StdEncoding.DecodeString(f.ManagerPublicKey)
|
||||||
|
if err != nil || len(pub) != 32 {
|
||||||
|
t.Fatalf("the fixture public key is not a 32-byte X25519 key")
|
||||||
|
}
|
||||||
|
priv, err := base64.StdEncoding.DecodeString(f.ManagerPrivateKey)
|
||||||
|
if err != nil || len(priv) != 32 {
|
||||||
|
t.Fatalf("the fixture private key is not 32 bytes")
|
||||||
|
}
|
||||||
|
blob, err := base64.StdEncoding.DecodeString(f.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the sealed value is not base64: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host's path: box.OpenAnonymous with the node's key pair.
|
||||||
|
var pubA, privA [32]byte
|
||||||
|
copy(pubA[:], pub)
|
||||||
|
copy(privA[:], priv)
|
||||||
|
out, ok := box.OpenAnonymous(nil, blob, &pubA, &privA)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("box.OpenAnonymous (the host's Unseal) FAILED to open the module's TS seal — " +
|
||||||
|
"the TypeScript crypto_box_seal has drifted from Go's box")
|
||||||
|
}
|
||||||
|
if string(out) != f.Plaintext {
|
||||||
|
t.Fatalf("opened to %q, expected %q", out, f.Plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it is exactly what secrets.Seal produces: a value this package can round-trip is one the TS
|
||||||
|
// module could equally have produced, so the two are interchangeable at the seam.
|
||||||
|
roundTrip, err := Seal(f.ManagerPublicKey, []byte(f.Plaintext))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rtBlob, _ := base64.StdEncoding.DecodeString(roundTrip)
|
||||||
|
back, ok := box.OpenAnonymous(nil, rtBlob, &pubA, &privA)
|
||||||
|
if !ok || string(back) != f.Plaintext {
|
||||||
|
t.Fatal("secrets.Seal did not round-trip under box.OpenAnonymous")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Produced by mesh-catalog anthropic-manager sealedbox.ts (crypto_box_seal). Proves that value the module seals to a node's public key opens under Go box.OpenAnonymous — the host's Unseal and mesh-control secrets.Seal/Open. Regenerate with the module's compiled seal().",
|
||||||
|
"managerPublicKey": "rJZ9OSnuCcU5MNi8iV0EK8c5nYN+Cx5A+q+miIIIoUc=",
|
||||||
|
"managerPrivateKey": "wGHx9hpbO1pyvLiw8oGwi31LBce3HscDiGhXpNU+wl4=",
|
||||||
|
"plaintext": "rt-a-refresh-token-only-the-manager-may-read",
|
||||||
|
"sealed": "/fI4OGzdLLQnhwv1IagCiS8DNhsjiaPdJTaLjZYBayxHa5xLZ5T74+00yxKHToAoMGimQ0pCrz5ykQPp6YPsSFUSO8Oujz48f1tl/xRyRtkbulVBBC6ylS0KAAM="
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user