mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
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:
parent
6102f533f3
commit
770bcda619
3 changed files with 117 additions and 1 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
81
lnclient/greenlight/provision_test.go
Normal file
81
lnclient/greenlight/provision_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue