adb-toolkit/backend_applock.go

216 lines
5.9 KiB
Go

package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/crypto/scrypt"
)
// App-lock: an optional password gate for ATK.
//
// Threat model (be honest about it — the Settings UI says the same): the launch
// gate and the "require password for destructive actions" window are enforced
// here in Go, so the ATK app itself cannot be driven into flashing/uninstalling
// without the password. They do NOT stop a fully-compromised computer from
// invoking `adb`/`fastboot` directly, outside ATK — nothing running as the same
// user can. This raises the bar against casual misuse and stops ATK being a
// turnkey attack surface; it is not a substitute for full-disk encryption or a
// locked bootloader.
//
// The password is never stored — only a per-install random salt + scrypt hash.
// dangerWindow is how long a successful UnlockDanger keeps destructive actions
// unlocked. Kept short so an unattended session re-locks quickly.
const dangerWindow = 5 * time.Minute
type appLockConfig struct {
Enabled bool `json:"enabled"`
Salt string `json:"salt"` // hex
Hash string `json:"hash"` // hex, scrypt(password, salt)
RequireForDanger bool `json:"requireForDanger"`
}
func appLockPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ATK", "applock.json"), nil
}
func loadAppLock() appLockConfig {
var c appLockConfig
p, err := appLockPath()
if err != nil {
return c
}
data, err := os.ReadFile(p)
if err != nil {
return c
}
_ = json.Unmarshal(data, &c)
return c
}
func saveAppLock(c appLockConfig) error {
p, err := appLockPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o600)
}
// scryptHash derives a 32-byte key. N=32768,r=8,p=1 is the interactive-login
// preset — a few tens of ms per attempt, which is the point.
func scryptHash(password string, salt []byte) (string, error) {
dk, err := scrypt.Key([]byte(password), salt, 1<<15, 8, 1, 32)
if err != nil {
return "", err
}
return hex.EncodeToString(dk), nil
}
func (c appLockConfig) verify(password string) (bool, error) {
if !c.Enabled || c.Hash == "" {
return true, nil // no lock configured → everything passes
}
salt, err := hex.DecodeString(c.Salt)
if err != nil {
return false, fmt.Errorf("app-lock config is corrupt")
}
got, err := scryptHash(password, salt)
if err != nil {
return false, err
}
return subtle.ConstantTimeCompare([]byte(got), []byte(c.Hash)) == 1, nil
}
// AppLockStatus reports whether the lock is enabled and whether destructive
// actions additionally require re-entering the password. Safe to call anytime.
func (a *App) AppLockStatus() map[string]bool {
c := loadAppLock()
return map[string]bool{
"enabled": c.Enabled && c.Hash != "",
"requireForDanger": c.RequireForDanger,
}
}
// VerifyAppPassword is used by the launch gate. Returns true on a correct
// password (or when no lock is set).
func (a *App) VerifyAppPassword(password string) (bool, error) {
return loadAppLock().verify(password)
}
// SetAppPassword sets or changes the launch password and enables the lock. When
// a password already exists, `current` must match it. Pass "" for `current` on
// first setup.
func (a *App) SetAppPassword(current, next string) error {
if len(next) < 4 {
return fmt.Errorf("password must be at least 4 characters")
}
c := loadAppLock()
if c.Enabled && c.Hash != "" {
ok, err := c.verify(current)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("current password is incorrect")
}
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return err
}
hash, err := scryptHash(next, salt)
if err != nil {
return err
}
c.Enabled = true
c.Salt = hex.EncodeToString(salt)
c.Hash = hash
return saveAppLock(c)
}
// DisableAppLock removes the lock entirely. The current password must match.
func (a *App) DisableAppLock(current string) error {
c := loadAppLock()
if !c.Enabled || c.Hash == "" {
return nil
}
ok, err := c.verify(current)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("password is incorrect")
}
return saveAppLock(appLockConfig{}) // wipe salt+hash
}
// SetRequireForDanger toggles the per-action re-auth requirement. Requires the
// current password so a passer-by at an unlocked session can't switch it off.
func (a *App) SetRequireForDanger(current string, require bool) error {
c := loadAppLock()
if !c.Enabled || c.Hash == "" {
return fmt.Errorf("set an app password first")
}
ok, err := c.verify(current)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("password is incorrect")
}
c.RequireForDanger = require
return saveAppLock(c)
}
// UnlockDanger opens the destructive-action window for dangerWindow on a correct
// password. Returns true if unlocked. Called by the frontend re-auth modal.
func (a *App) UnlockDanger(password string) (bool, error) {
c := loadAppLock()
ok, err := c.verify(password)
if err != nil {
return false, err
}
if !ok {
return false, nil
}
a.dangerMu.Lock()
a.dangerUntil = time.Now().Add(dangerWindow)
a.dangerMu.Unlock()
return true, nil
}
// requireDangerUnlocked is the backend gate every destructive method calls
// first. It is a no-op unless the lock is enabled AND RequireForDanger is set.
// When armed, it fails closed until UnlockDanger has been called recently.
func (a *App) requireDangerUnlocked() error {
c := loadAppLock()
if !c.Enabled || c.Hash == "" || !c.RequireForDanger {
return nil
}
a.dangerMu.Lock()
until := a.dangerUntil
a.dangerMu.Unlock()
if time.Now().Before(until) {
return nil
}
// Sentinel prefix the frontend recognises to pop the re-auth modal.
return fmt.Errorf("DANGER_LOCKED: app password required for this action")
}