diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 7e8ddc6f4..1d34a8da9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -60,7 +60,7 @@ func manager() (*Manager, error) { if err != nil { return nil, err } - keys, err := keypair(dir) + keys, err := keypair(dir, say) if err != nil { return nil, err } @@ -131,28 +131,60 @@ func configLevels(node string) (map[string]string, error) { } // keypair is this module's own, made once in its state directory; the private half never leaves it. -// Written whole, then linked into place, so a second process reads the first's key and never half of it. -func keypair(dir string) (KeyPair, error) { +// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it +// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said +// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old. +func keypair(dir string, log func(string, ...any)) (KeyPair, error) { file := filepath.Join(dir, "manager-key.json") if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { - k, err := GenerateKeyPair() - if err != nil { + if err := writeKeyPair(dir, file, false); err != nil { return KeyPair{}, err } - raw, _ := json.Marshal(k) - tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return KeyPair{}, err - } - _ = os.Link(tmp, file) // fails when another made it first, which is right - _ = os.Remove(tmp) } + k, err := readKeyPair(file) + if err == nil { + return k, nil + } + log("the key pair %s cannot be read (%v); a new one is made in its place", file, err) + if err := writeKeyPair(dir, file, true); err != nil { + return KeyPair{}, err + } + return readKeyPair(file) +} + +func readKeyPair(file string) (KeyPair, error) { raw, err := os.ReadFile(file) if err != nil { return KeyPair{}, err } var k KeyPair - return k, json.Unmarshal(raw, &k) + if err := json.Unmarshal(raw, &k); err != nil { + return KeyPair{}, err + } + if k.PublicKey == "" || k.PrivateKey == "" { + return KeyPair{}, errors.New("it holds no whole key pair") + } + return k, nil +} + +// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one +// that cannot be read. +func writeKeyPair(dir, file string, replace bool) error { + k, err := GenerateKeyPair() + if err != nil { + return err + } + raw, _ := json.Marshal(k) + tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + if replace { + return os.Rename(tmp, file) + } + _ = os.Link(tmp, file) // fails when another made it first, which is right + _ = os.Remove(tmp) + return nil } // ---- the long-running half --------------------------------------------------------------------------- diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go index 87038f0d7..2f096ba9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "fmt" "os" + "path/filepath" + "strings" "testing" ) @@ -41,3 +44,29 @@ func TestABoxOpensOnlyForItsRecipient(t *testing.T) { t.Fatal("a box opened for another key") } } + +// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is +// replaced in its place by a new one, said in its journal; the new one is read back by the next start. +func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) { + if os.Geteuid() == 0 { + t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account") + } + dir := t.TempDir() + file := filepath.Join(dir, "manager-key.json") + if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil { + t.Fatal(err) + } + var said []string + log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) } + k, err := keypair(dir, log) + if err != nil { + t.Fatalf("a key pair it cannot read stops the manager: %v", err) + } + if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") { + t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said) + } + again, err := keypair(dir, log) + if err != nil || again != k || len(said) != 1 { + t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said) + } +}