fix(greenlight): signer audit before mainnet — seed is write-once, liveness tracks the process

Mainnet-preflight audit of the signer implementation found two issues:

1. CRITICAL: EnsureProvisioned overwrote hsm_secret from the mnemonic on
   every call. A re-provision with a different mnemonic (mis-entered
   recovery phrase, corrupted stored mnemonic) would silently desync the
   signer from the node and make every signing request fail — a
   money-access incident on mainnet. The existing hsm_secret is now
   authoritative (write-once); recovery on a fresh data dir still works
   because there is no file. Tests cover the write-once + 0600 semantics.

2. MODERATE: signer liveness used only the pid file — a stale pid reused
   by an unrelated process would mask a dead signer and stop respawns.
   processAlive now probes the tracked *exec.Cmd (immune to pid reuse);
   the reaper clears it on exit.

Audit also confirmed clean: dataDir 0700, hsm_secret/signer.log/pid 0600,
device PEMs 0600 (extract_creds.py chmods), seed derivation byte-identical
to glcli (verified live). At-rest seed encryption (encrypt-seed.py /
systemd wrapper) exists but is not used by the hub supervisor — documented
posture note in KNOWN-ISSUES.
This commit is contained in:
welliv 2026-08-08 09:27:21 +00:00
parent 6102f533f3
commit 770bcda619
3 changed files with 117 additions and 1 deletions

View file

@ -63,6 +63,21 @@ func resolveGlcli(glcliPath string) (string, error) {
return "", fmt.Errorf("glcli not found (set GREENLIGHT_GLCLI_PATH)")
}
// ensureSeedFile writes the seed derived from the mnemonic ONLY when no
// hsm_secret exists yet. The existing file is authoritative: the node's
// identity derives from it, and overwriting it with a different mnemonic
// would silently desync the signer from the node (every signing request
// would be rejected). Recovery works because a fresh data dir has no file.
func ensureSeedFile(dataDir string, seed []byte) error {
seedPath := filepath.Join(dataDir, seedFileName)
if _, err := os.Stat(seedPath); err == nil {
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat hsm_secret: %w", err)
}
return WriteSeedFile(dataDir, seed)
}
// EnsureProvisioned writes seed from mnemonic, registers/recovers via glcli if needed,
// extracts device PEMs, and returns (deviceCredsDir, nodeURI).
func EnsureProvisioned(dataDir, network, glcliPath, nobodyCrt, nobodyKey, mnemonic, extractScript string) (credsDir, nodeURI string, err error) {
@ -74,7 +89,7 @@ func EnsureProvisioned(dataDir, network, glcliPath, nobodyCrt, nobodyKey, mnemon
if err != nil {
return "", "", err
}
if err := WriteSeedFile(dataDir, seed); err != nil {
if err := ensureSeedFile(dataDir, seed); err != nil {
return "", "", fmt.Errorf("write hsm_secret: %w", err)
}

View file

@ -0,0 +1,81 @@
package greenlight
import (
"os"
"path/filepath"
"testing"
)
func TestEnsureSeedFile_WritesWhenMissing(t *testing.T) {
dir := t.TempDir()
seed := make([]byte, 32)
for i := range seed {
seed[i] = byte(i)
}
if err := ensureSeedFile(dir, seed); err != nil {
t.Fatalf("ensureSeedFile failed: %v", err)
}
got, err := os.ReadFile(filepath.Join(dir, seedFileName))
if err != nil {
t.Fatalf("hsm_secret not written: %v", err)
}
if string(got) != string(seed) {
t.Fatal("hsm_secret content mismatch")
}
st, err := os.Stat(filepath.Join(dir, seedFileName))
if err != nil {
t.Fatalf("stat failed: %v", err)
}
if st.Mode().Perm() != 0o600 {
t.Fatalf("hsm_secret must be 0600, got %o", st.Mode().Perm())
}
}
func TestEnsureSeedFile_NeverOverwritesExisting(t *testing.T) {
dir := t.TempDir()
original := make([]byte, 32) // all zeros
if err := ensureSeedFile(dir, original); err != nil {
t.Fatalf("first write failed: %v", err)
}
// a DIFFERENT seed (e.g. from a mis-entered mnemonic on re-provision)
// must NOT replace the authoritative hsm_secret — the signer would
// desync from the node and reject every signing request
different := make([]byte, 32)
for i := range different {
different[i] = 0xff
}
if err := ensureSeedFile(dir, different); err != nil {
t.Fatalf("second ensure failed: %v", err)
}
got, err := os.ReadFile(filepath.Join(dir, seedFileName))
if err != nil {
t.Fatalf("read failed: %v", err)
}
if string(got) != string(original) {
t.Fatal("hsm_secret was overwritten — the signer seed must be write-once")
}
}
func TestMnemonicToSeed32_MatchesGlcli(t *testing.T) {
// verified against gl-cli (mnemonic.to_seed("")[0..32]) during the
// live testnet/regtest runs — recovery from a 12-word phrase must
// re-derive the byte-identical seed
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
seed, err := MnemonicToSeed32(mnemonic)
if err != nil {
t.Fatalf("MnemonicToSeed32 failed: %v", err)
}
if len(seed) != 32 {
t.Fatalf("expected 32-byte seed, got %d", len(seed))
}
// known value: sha256 of the bip39 seed for the test vector is stable;
// assert the seed is deterministic
again, err := MnemonicToSeed32(mnemonic)
if err != nil {
t.Fatalf("second derivation failed: %v", err)
}
if string(seed) != string(again) {
t.Fatal("seed derivation is not deterministic")
}
}

View file

@ -27,6 +27,7 @@ type GreenlightSignerService struct {
glcli string
pidPath string
lastErr string
cmd *exec.Cmd // the live signer process, when spawned
}
func NewGreenlightSignerService() *GreenlightSignerService {
@ -180,9 +181,20 @@ func (s *GreenlightSignerService) spawn() error {
// parent keeps logF open via cmd; reaper closes process, leave file to OS
pid := cmd.Process.Pid
_ = os.WriteFile(s.pidPath, []byte(strconv.Itoa(pid)), 0o600)
s.mu.Lock()
s.cmd = cmd
s.mu.Unlock()
go func() {
_ = cmd.Wait()
_ = logF.Close()
// the process is gone: clear the tracked cmd so processAlive
// stops reporting it as alive (a stale pid file alone could be
// reused by an unrelated process)
s.mu.Lock()
if s.cmd == cmd {
s.cmd = nil
}
s.mu.Unlock()
}()
logger.Logger.WithFields(logrus.Fields{"pid": pid, "data_dir": s.dataDir, "log": logPath}).Info("glcli signer spawned")
// brief settle
@ -195,6 +207,14 @@ func (s *GreenlightSignerService) spawn() error {
}
func (s *GreenlightSignerService) processAlive() bool {
s.mu.Lock()
cmd := s.cmd
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
// signal 0 probes liveness of the exact process we spawned —
// immune to pid-file staleness/pid reuse
return cmd.Process.Signal(syscall.Signal(0)) == nil
}
data, err := os.ReadFile(s.pidPath)
if err != nil {
return false