Phase C of model-access (ADR 0050). Refresh above calls an in-process VendorRefresher, which would open the at-rest envelope inside the control plane's own process. Anthropic must not: its refresh runs on the manager node. So add SubmitRefresh, the companion that publishes a refresh a manager node already performed -- it is given only the new access token in the clear (sealed per holder, as any accepted key) and an opaque re-sealed refresh envelope (stored unopened). The refresh token in the clear never crosses this boundary. The reseal-and-publish half is extracted and shared with Refresh, so the sealing logic is one implementation. CLI: licence grant (print the opaque envelope), set-grant (store a module-produced envelope -- adoption), submit-refresh (access token + optional rotated envelope). Tests defend that the manager alone opens the refresh token and the control plane never holds it in the clear. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
160 lines
5.9 KiB
Go
160 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|