package gzev import ( "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "syscall" "time" ) type recoveryTicket struct { Code string `json:"code"` Expires time.Time `json:"expires"` } func recoveryLock(path string, fn func() error) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600) if err != nil { return err } defer f.Close() if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { return err } defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) return fn() } func EnsureRecoveryCode(path string) (string, error) { var code string err := recoveryLock(path, func() error { b, err := os.ReadFile(path) var ticket recoveryTicket if err == nil && json.Unmarshal(b, &ticket) == nil && ticket.Expires.After(time.Now()) && len(ticket.Code) == 48 { code = ticket.Code return nil } nonce := make([]byte, 24) if _, err := rand.Read(nonce); err != nil { return err } code = hex.EncodeToString(nonce) ticket = recoveryTicket{Code: code, Expires: time.Now().Add(48 * time.Hour)} f, err := os.CreateTemp(filepath.Dir(path), ".recovery-*") if err != nil { return err } defer os.Remove(f.Name()) if err := json.NewEncoder(f).Encode(ticket); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } return os.Rename(f.Name(), path) }) return code, err } // ConsumeRecovery serializes all writers and only consumes a ticket after a successful update. func ConsumeRecovery(path, code string, action func() error) error { return recoveryLock(path, func() error { b, err := os.ReadFile(path) var ticket recoveryTicket if err != nil || json.Unmarshal(b, &ticket) != nil || !ticket.Expires.After(time.Now()) || len(code) != 48 || subtle.ConstantTimeCompare([]byte(code), []byte(ticket.Code)) != 1 { return errors.New("invalid recovery code") } if err := action(); err != nil { return err } return os.Remove(path) }) }