diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go index 2544ab9..23f417c 100644 --- a/cmd/mesh-control/licence.go +++ b/cmd/mesh-control/licence.go @@ -7,8 +7,11 @@ import ( "errors" "flag" "fmt" + "io" "os" "strings" + + "github.com/novox/mesh-control/internal/secrets" ) // licenceCommand is everything about model access the mesh holds. @@ -18,7 +21,8 @@ import ( // them too, because the whole point is saying which one a given consumer uses. func licenceCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("licence add|list|use|release|key|manager|refresh|forget") + return errors.New( + "licence add|list|use|release|key|manager|grant|set-grant|refresh|submit-refresh|forget") } switch args[0] { case "add": @@ -33,13 +37,20 @@ func licenceCommand(ctx context.Context, args []string) error { return licenceKey(ctx, args[1:]) case "manager": return licenceManager(ctx, args[1:]) + case "grant": + return licenceGrant(ctx, args[1:]) + case "set-grant": + return licenceSetGrant(ctx, args[1:]) case "refresh": return licenceRefresh(ctx, args[1:]) + case "submit-refresh": + return licenceSubmitRefresh(ctx, args[1:]) case "forget": return licenceForget(ctx, args[1:]) } 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, grant, set-grant, refresh, "+ + "submit-refresh or forget", args[0]) } func licenceAdd(ctx context.Context, args []string) error { @@ -256,6 +267,216 @@ func licenceManager(ctx context.Context, args []string) error { return nil } +// envelopeJSON is the wire shape of a refresh-token at-rest envelope on this command surface: the +// three opaque parts of secrets.AtRest and nothing else. +// +// **Every field of it is ciphertext or a public key.** `token` is the refresh token under a data +// key, `wrapped_key` is that data key sealed to the manager node, `manager_key` is the manager's +// public sealing key. None of them is the refresh token in the clear — which is why this surface may +// print one out (`grant`) and read one in (`set-grant`, `submit-refresh`) without the control plane +// ever holding a refresh token it could read. The manager runtime, on the manager node, is the only +// place these open (novox/hq ADR 0050, Phase C). +type envelopeJSON struct { + Token string `json:"token"` + WrappedKey string `json:"wrapped_key"` + ManagerKey string `json:"manager_key"` +} + +func (e envelopeJSON) atRest() secrets.AtRest { + return secrets.AtRest{Token: e.Token, WrappedKey: e.WrappedKey, ManagerKey: e.ManagerKey} +} + +func envelopeOf(a secrets.AtRest) envelopeJSON { + return envelopeJSON{Token: a.Token, WrappedKey: a.WrappedKey, ManagerKey: a.ManagerKey} +} + +// readEnvelope reads an at-rest envelope from a file or standard input as JSON. +func readEnvelope(from string) (envelopeJSON, error) { + var raw []byte + var err error + if from != "" { + raw, err = os.ReadFile(from) + } else { + raw, err = readAllStdin() + } + if err != nil { + return envelopeJSON{}, err + } + var env envelopeJSON + if err := json.Unmarshal(raw, &env); err != nil { + return envelopeJSON{}, fmt.Errorf("the refresh-token envelope is not JSON: %w", err) + } + if env.Token == "" || env.WrappedKey == "" || env.ManagerKey == "" { + return envelopeJSON{}, errors.New( + "an at-rest envelope is {token, wrapped_key, manager_key}, and one part is missing") + } + return env, nil +} + +func readAllStdin() ([]byte, error) { + reader := bufio.NewReader(os.Stdin) + return io.ReadAll(reader) +} + +// licenceGrant prints a licence's refresh-token envelope, so the manager runtime can fetch the +// opaque thing it will open on the manager node (novox/hq ADR 0050, Phase C). +// +// **What is printed is ciphertext.** The envelope is the refresh token sealed at rest to the manager +// node's key; it opens nowhere but that node. Printing it here is how the manager runtime — which +// does not read this database directly — is handed the envelope to open, and it discloses nothing a +// copy of the store did not already hold. +func licenceGrant(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("licence grant ") + } + name := args[0] + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + at, ok, err := held.RefreshGrant(ctx, name) + if err != nil { + return err + } + if !ok { + // Said, not printed as an empty object: a licence with no grant and a failed read must not + // look the same to whatever parses this. + return fmt.Errorf( + "%q has no refresh token stored; its manager adopts one first with `licence set-grant %s`", + name, name) + } + out, err := json.Marshal(envelopeOf(at)) + if err != nil { + return err + } + fmt.Println(string(out)) + return nil +} + +// licenceSetGrant stores a refresh-token envelope the manager runtime produced — adoption, and the +// re-seal after a rotation done outside this process (novox/hq ADR 0050, Phase C). +// +// **It takes an envelope, never a refresh token.** The manager node reads the operator's refresh +// token, seals it at rest to its own key, and hands the sealed envelope 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 envelope 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 envelope 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 [--file ]") + } + name := positionals[0] + + env, err := readEnvelope(*from) + if err != nil { + return err + } + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + if err := held.SetRefreshGrant(ctx, name, env.atRest()); err != nil { + return err + } + fmt.Printf("%s now holds a refresh token for %s, encrypted at rest and readable by that node "+ + "alone.\n the control plane stored the envelope without opening 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 refresh-token envelope, 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 [--access-file ] [--grant-file ]") + } + 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 envelope is optional: absent, the stored refresh token is left exactly as it was. + var rotated *secrets.AtRest + if *grantFrom != "" { + env, err := readEnvelope(*grantFrom) + if err != nil { + return err + } + at := env.atRest() + rotated = &at + } + + 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, rotated, 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 rotated != nil { + rotatedNote = "the rotated refresh token replaced the stored envelope, 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 +} + // licenceRefresh mints a new access token for a refreshable-grant licence and seals it to every // holder (novox/hq ADR 0050). The refresh token stays with the manager and is never delivered. // diff --git a/internal/licences/licences.go b/internal/licences/licences.go index bdf59ac..4664798 100644 --- a/internal/licences/licences.go +++ b/internal/licences/licences.go @@ -482,8 +482,106 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys "the refresh produced no access token for %q, so nothing was resealed", licence) } - // Reseal the new access token to the holders that exist now — the same set Accept seals to — and - // keep no readable copy. + // The reseal-and-publish half, shared with SubmitRefresh: the new access token is sealed to every + // holder that exists now, and a rotated refresh token replaces the stored envelope — never seen + // in the clear either way. + sealed, err := resealAndPublish(ctx, tx, licence, result.AccessToken, result.NewAtRest, 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 string, newAtRest *secrets.AtRest, 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 ", licence, licence) + } + + sealed, err := resealAndPublish(ctx, tx, licence, accessToken, newAtRest, 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 current holder and, if one is given, replaces +// the stored refresh envelope 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 refresh token is never touched here — a rotated one arrives already re-sealed at rest, and is +// stored as the opaque envelope it is. `KeyFor` can therefore only ever deliver the access token. +func resealAndPublish( + ctx context.Context, tx pgx.Tx, licence, accessToken string, newAtRest *secrets.AtRest, + keys SealingKeys, +) (int, error) { holders, err := holdersTx(ctx, tx, licence) if err != nil { return 0, err @@ -498,7 +596,7 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys return 0, fmt.Errorf( "%s has no sealing key, so the new access token cannot be sealed to it", h.Node) } - blob, err := secrets.Seal(key, []byte(result.AccessToken)) + blob, err := secrets.Seal(key, []byte(accessToken)) if err != nil { return 0, err } @@ -511,26 +609,20 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys sealed++ } - // The refresh token stays put unless the vendor rotated it, in which case the refresher returned - // it already re-encrypted at rest — replaced here without ever being seen in the clear. - if result.NewAtRest != nil { - if result.NewAtRest.Token == "" || result.NewAtRest.WrappedKey == "" || - result.NewAtRest.ManagerKey == "" { + // The refresh token stays put unless the vendor rotated it, in which case it arrived already + // re-encrypted at rest — replaced here without ever being seen in the clear. + if newAtRest != nil { + if newAtRest.Token == "" || newAtRest.WrappedKey == "" || newAtRest.ManagerKey == "" { return 0, fmt.Errorf( "the refresh returned an incomplete re-sealed refresh token for %q", licence) } if _, err := tx.Exec(ctx, `update refresh_grant set token = $2, wrapped_key = $3, manager_key = $4, updated_at = now() where licence = $1`, - licence, result.NewAtRest.Token, result.NewAtRest.WrappedKey, - result.NewAtRest.ManagerKey); err != nil { + licence, newAtRest.Token, newAtRest.WrappedKey, newAtRest.ManagerKey); err != nil { return 0, err } } - - if err := tx.Commit(ctx); err != nil { - return 0, err - } return sealed, nil } diff --git a/internal/licences/submitrefresh_test.go b/internal/licences/submitrefresh_test.go new file mode 100644 index 0000000..05ece6e --- /dev/null +++ b/internal/licences/submitrefresh_test.go @@ -0,0 +1,159 @@ +package licences + +import ( + "strings" + "testing" + + "github.com/novox/mesh-control/internal/secrets" +) + +// SubmitRefresh is the Phase-C boundary: a refresh a manager NODE performed is published here, and +// the control plane is given only the access token in the clear and an opaque re-sealed refresh +// envelope — never the refresh token. These tests defend that the boundary keeps its shape. + +// A submitted refresh seals the access token to every holder, exactly as an in-process refresh does, +// and delivers no refresh token to anybody. +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 envelope inside this process. + _, _, holders, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) + + sealed, err := held.SubmitRefresh(ctx, "personal", "at-from-the-manager-node", nil, keys) + if err != nil { + t.Fatal(err) + } + if sealed != 2 { + t.Fatalf("%d holder(s) were resealed, expected 2", sealed) + } + + for node, open := range holders { + blob, err := held.KeyFor(ctx, "personal", node, "assistant") + if err != nil { + t.Fatal(err) + } + 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 stored at rest 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", nil, keys); err != nil { + t.Fatal(err) + } + if grantRow(t, held, ctx) != before { + t.Fatal("a submit with no rotation changed the stored refresh token") + } + + at, ok, err := held.RefreshGrant(ctx, "personal") + if err != nil || !ok { + t.Fatalf("the grant is not stored: ok=%v err=%v", ok, err) + } + got, err := secrets.OpenAtRest(at, managerPub, managerPriv) + if err != nil { + t.Fatal(err) + } + if got != "rt-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 _, err := secrets.OpenAtRest(at, otherPub, otherPriv); err == nil { + t.Fatal("a node that is not the manager opened the refresh token") + } +} + +// A submit that carries a rotated envelope replaces the stored one — and the control plane stored it +// without opening it: only the manager node reads the rotated token back. +func TestSubmitRefreshWithRotationReplacesTheEnvelopeUnopened(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 at rest; the control plane is handed only + // this envelope. + rotated, err := secrets.SealAtRest("rt-a-rotated-refresh-token", managerPub) + if err != nil { + t.Fatal(err) + } + if _, err := held.SubmitRefresh(ctx, "personal", "at-access", &rotated, keys); err != nil { + t.Fatal(err) + } + if grantRow(t, held, ctx) == before { + t.Fatal("the rotated refresh token did not replace the stored envelope") + } + + at, ok, err := held.RefreshGrant(ctx, "personal") + if err != nil || !ok { + t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err) + } + got, err := secrets.OpenAtRest(at, managerPub, managerPriv) + if err != nil { + t.Fatal(err) + } + if got != "rt-a-rotated-refresh-token" { + t.Fatalf("the stored grant opened to %q, not the rotated token", got) + } +} + +// A 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", " ", nil, 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", nil, + 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", nil, + 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) + } +}