216 lines
5.9 KiB
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")
|
|
}
|