diff --git a/cmd/serve.go b/cmd/serve.go index 49eaac1..e028a65 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -20,6 +20,7 @@ import ( "github.com/maxghenis/openmessage/internal/app" "github.com/maxghenis/openmessage/internal/db" + "github.com/maxghenis/openmessage/internal/googlecookies" "github.com/maxghenis/openmessage/internal/importer" "github.com/maxghenis/openmessage/internal/notify" "github.com/maxghenis/openmessage/internal/telemetry" @@ -227,8 +228,7 @@ func RunServe(logger zerolog.Logger, args ...string) error { } lastAttempt = time.Now() g := a.GoogleStatus() - hasRefreshScript := strings.TrimSpace(os.Getenv("OPENMESSAGE_COOKIE_REFRESH_SCRIPT")) != "" - switch planGoogleReconnect(g, hasRefreshScript) { + switch planGoogleReconnect(g, canRefreshGoogleCookies()) { case googleReconnectSkip: return case googleReconnectPark: @@ -244,7 +244,7 @@ func RunServe(logger zerolog.Logger, args ...string) error { // then reconnect, even if the session was flagged for repair. logger.Info().Msg("Google auth expired - refreshing Chrome cookies before reconnect") ctx, cancel := context.WithTimeout(context.Background(), googleCookieRefreshTimeout) - err := refreshGoogleSessionCookies(ctx) + err := refreshGoogleSessionCookies(ctx, a.SessionPath) cancel() if err != nil { logger.Warn().Err(err).Msg("Google cookie refresh before reconnect failed") @@ -678,27 +678,53 @@ func planGoogleReconnect(g app.GoogleStatusSnapshot, hasRefreshScript bool) goog return googleReconnectRetry } -var refreshGoogleSessionCookies = func(ctx context.Context) error { +// canRefreshGoogleCookies reports whether an expired Google session can be +// recovered automatically — either via a configured external refresh script or +// the built-in native refresh (macOS with a Chrome profile). When neither is +// available (e.g. a Linux install with no script), planGoogleReconnect parks +// the session and the UI prompts a manual re-pair instead of spinning 401s. +func canRefreshGoogleCookies() bool { + if strings.TrimSpace(os.Getenv("OPENMESSAGE_COOKIE_REFRESH_SCRIPT")) != "" { + return true + } + return googlecookies.NativeSupported() +} + +// refreshGoogleSessionCookies rewrites the Google cookies in sessionPath. It +// prefers an explicitly configured OPENMESSAGE_COOKIE_REFRESH_SCRIPT (so the +// operator can override the mechanism), otherwise falls back to the built-in +// native refresh. Returning nil with no work done is fine — the caller +// reconnects afterwards regardless. +var refreshGoogleSessionCookies = func(ctx context.Context, sessionPath string) error { script := strings.TrimSpace(os.Getenv("OPENMESSAGE_COOKIE_REFRESH_SCRIPT")) - if script == "" { + if script != "" { + if _, err := os.Stat(script); err != nil { + return fmt.Errorf("refresh script unavailable at %s: %w", script, err) + } + cmd := exec.CommandContext(ctx, script, "--quiet", "--no-backup") + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + return fmt.Errorf("refresh Google cookies timed out after %s", googleCookieRefreshTimeout) + } + if err != nil { + detail := strings.TrimSpace(string(output)) + if detail == "" { + return fmt.Errorf("refresh Google cookies: %w", err) + } + return fmt.Errorf("refresh Google cookies: %w: %s", err, detail) + } return nil } - if _, err := os.Stat(script); err != nil { - return fmt.Errorf("refresh script unavailable at %s: %w", script, err) - } - cmd := exec.CommandContext(ctx, script, "--quiet", "--no-backup") - cmd.Env = os.Environ() - output, err := cmd.CombinedOutput() - if ctx.Err() != nil { - return fmt.Errorf("refresh Google cookies timed out after %s", googleCookieRefreshTimeout) + if !googlecookies.NativeSupported() { + return nil } - if err != nil { - detail := strings.TrimSpace(string(output)) - if detail == "" { - return fmt.Errorf("refresh Google cookies: %w", err) + if err := googlecookies.Refresh(ctx, googlecookies.DefaultChromeProfile(), sessionPath); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("refresh Google cookies timed out after %s", googleCookieRefreshTimeout) } - return fmt.Errorf("refresh Google cookies: %w: %s", err, detail) + return fmt.Errorf("refresh Google cookies: %w", err) } return nil } diff --git a/cmd/serve_test.go b/cmd/serve_test.go index cb07ce9..85d703b 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -145,10 +145,14 @@ func TestIMessageSyncSupported(t *testing.T) { func TestRefreshGoogleSessionCookiesSkipsWhenUnconfigured(t *testing.T) { t.Setenv("OPENMESSAGE_COOKIE_REFRESH_SCRIPT", "") + // Point the native refresh at an empty profile dir so NativeSupported() + // is false regardless of whether the CI host has a real Chrome profile; + // this test asserts the no-script, no-native path is a clean no-op. + t.Setenv("OPENMESSAGE_CHROME_PROFILE", t.TempDir()) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - if err := refreshGoogleSessionCookies(ctx); err != nil { + if err := refreshGoogleSessionCookies(ctx, ""); err != nil { t.Fatalf("refreshGoogleSessionCookies(): %v", err) } } @@ -168,7 +172,7 @@ func TestRefreshGoogleSessionCookiesUsesEnvScript(t *testing.T) { // spurious "timed out" failure. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := refreshGoogleSessionCookies(ctx); err != nil { + if err := refreshGoogleSessionCookies(ctx, ""); err != nil { t.Fatalf("refreshGoogleSessionCookies(): %v", err) } diff --git a/internal/googlecookies/googlecookies.go b/internal/googlecookies/googlecookies.go new file mode 100644 index 0000000..ea02b6a --- /dev/null +++ b/internal/googlecookies/googlecookies.go @@ -0,0 +1,339 @@ +// Package googlecookies refreshes the Google account cookies inside +// OpenMessage's session.json from a local Chrome profile. +// +// Google Messages web sessions authenticate with Google account cookies that +// rotate roughly every 30 minutes. When the backend has been offline long +// enough (laptop asleep, travel), the stored cookies expire and every token +// refresh returns HTTP 401 — the session looks dead, but the phone-side +// device link is usually still intact. Rewriting auth_data.cookies with fresh +// values from the user's signed-in Chrome profile and reconnecting revives it +// with no re-pairing. +// +// This is the native (in-process) equivalent of +// scripts/refresh-google-session-cookies-{linux,macos}.py, so app installs +// self-heal without any external script configured. +package googlecookies + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "golang.org/x/crypto/pbkdf2" + _ "modernc.org/sqlite" +) + +type requiredCookie struct { + host string + name string +} + +var requiredCookies = []requiredCookie{ + {"messages.google.com", "OSID"}, + {".google.com", "SID"}, + {".google.com", "HSID"}, + {".google.com", "SSID"}, + {".google.com", "APISID"}, + {".google.com", "SAPISID"}, +} + +var hostPriority = map[string]int{ + "messages.google.com": 0, + ".google.com": 1, + "accounts.google.com": 2, +} + +// DefaultChromeProfile returns the Chrome profile directory to read cookies +// from, honouring the same OPENMESSAGE_CHROME_PROFILE override as the +// standalone refresh scripts. +func DefaultChromeProfile() string { + if p := strings.TrimSpace(os.Getenv("OPENMESSAGE_CHROME_PROFILE")); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return defaultChromeProfileDir(home) +} + +// Refresh reads Google cookies from the Chrome profile and rewrites +// auth_data.cookies in sessionPath. It never logs or returns cookie values. +func Refresh(ctx context.Context, profile, sessionPath string) error { + if profile == "" { + return fmt.Errorf("no Chrome profile directory") + } + secret, err := chromeSafeStorageSecret(ctx) + if err != nil { + return fmt.Errorf("chrome safe storage secret: %w", err) + } + cookies, err := LoadChromeCookies(profile, secret) + if err != nil { + return err + } + return UpdateSessionCookies(sessionPath, cookies) +} + +// DeriveKey derives the AES-128 key Chrome uses for cookie encryption. +func DeriveKey(secret []byte, iterations int) []byte { + return pbkdf2.Key(secret, []byte("saltysalt"), iterations, 16, sha1.New) +} + +// DecryptCookie decrypts one Chrome encrypted_value. It returns ("", nil) for +// values without a v10/v11 prefix (unencrypted or unknown scheme). +func DecryptCookie(encrypted []byte, host string, key []byte) (string, error) { + if len(encrypted) < 3 { + return "", nil + } + prefix := string(encrypted[:3]) + if prefix != "v10" && prefix != "v11" { + return "", nil + } + ciphertext := encrypted[3:] + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return "", fmt.Errorf("ciphertext length %d not a block multiple", len(ciphertext)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + iv := []byte(" ") // 16 spaces + padded := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(padded, ciphertext) + + padLen := int(padded[len(padded)-1]) + if padLen < 1 || padLen > aes.BlockSize || padLen > len(padded) { + return "", fmt.Errorf("invalid cookie padding") + } + plain := padded[:len(padded)-padLen] + + // Chrome 130+ prepends SHA256(host_key) to the plaintext. + hostHash := sha256.Sum256([]byte(host)) + if len(plain) >= 32 && strings.HasPrefix(string(plain), string(hostHash[:])) { + plain = plain[32:] + } + if !utf8.Valid(plain) { + return "", fmt.Errorf("decrypted cookie is not valid UTF-8") + } + return string(plain), nil +} + +// LoadChromeCookies reads the profile's cookie DB and returns the decrypted +// Google cookies, trying both known PBKDF2 iteration counts and keeping the +// best-scoring result. It fails if any required session cookie is missing. +func LoadChromeCookies(profile string, secret []byte) (map[string]string, error) { + dbCopy, cleanup, err := snapshotCookieDB(profile) + if err != nil { + return nil, err + } + defer cleanup() + + rows, err := readCookieRows(dbCopy) + if err != nil { + return nil, err + } + + var best *attempt + // 1003 is macOS, 1 is Linux/basic; trying both keeps the loader portable. + for _, iterations := range []int{1003, 1} { + key := DeriveKey(secret, iterations) + att := &attempt{values: map[string]hostValue{}} + for _, row := range rows { + value := row.plainValue + if value == "" && len(row.encrypted) > 0 { + decrypted, err := DecryptCookie(row.encrypted, row.host, key) + if err != nil { + att.failed++ + continue + } + value = decrypted + } + if value == "" { + continue + } + att.ok++ + prev, exists := att.values[row.name] + if !exists || priorityOf(row.host) < priorityOf(prev.host) { + att.values[row.name] = hostValue{host: row.host, value: value} + } + } + for _, req := range requiredCookies { + if hv, ok := att.values[req.name]; ok && hv.host == req.host { + att.present++ + } + } + if best == nil || betterAttempt(att, best) { + best = att + } + } + + var missing []string + for _, req := range requiredCookies { + if hv, ok := best.values[req.name]; !ok || hv.host != req.host { + missing = append(missing, req.host+":"+req.name) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing required cookies: %s", strings.Join(missing, ", ")) + } + + out := make(map[string]string, len(best.values)) + for name, hv := range best.values { + out[name] = hv.value + } + return out, nil +} + +type hostValue struct { + host string + value string +} + +type attempt struct { + values map[string]hostValue + present int + ok int + failed int +} + +type cookieRow struct { + host string + name string + encrypted []byte + plainValue string +} + +func priorityOf(host string) int { + if p, ok := hostPriority[host]; ok { + return p + } + return 10 +} + +func betterAttempt(a, b *attempt) bool { + if a.present != b.present { + return a.present > b.present + } + if a.ok != b.ok { + return a.ok > b.ok + } + return a.failed < b.failed +} + +// snapshotCookieDB copies the profile's cookie DB (with WAL/SHM sidecars) to +// a temp dir so we read the freshest values even while Chrome holds the live +// file — Google session cookies rotate ~30 minutes, so staleness matters. +func snapshotCookieDB(profile string) (string, func(), error) { + candidates := []string{ + filepath.Join(profile, "Network", "Cookies"), + filepath.Join(profile, "Cookies"), + } + var src string + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + src = c + break + } + } + if src == "" { + return "", nil, fmt.Errorf("chrome cookie DB not found under %s", profile) + } + + tmpDir, err := os.MkdirTemp("", "om-cookie-refresh-") + if err != nil { + return "", nil, err + } + cleanup := func() { os.RemoveAll(tmpDir) } + dst := filepath.Join(tmpDir, "Cookies") + if err := copyFile(src, dst); err != nil { + cleanup() + return "", nil, err + } + for _, suffix := range []string{"-wal", "-shm"} { + side := src + suffix + if _, err := os.Stat(side); err == nil { + if err := copyFile(side, dst+suffix); err != nil { + cleanup() + return "", nil, err + } + } + } + return dst, cleanup, nil +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o600) +} + +// readCookieRows opens the snapshot read-write so SQLite replays the WAL, +// making recently rotated cookies visible. +func readCookieRows(dbPath string) ([]cookieRow, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + defer db.Close() + + rows, err := db.Query(` + select host_key, name, encrypted_value, value + from cookies + where host_key in ('.google.com','messages.google.com','accounts.google.com') + or host_key like '%.google.com'`) + if err != nil { + return nil, fmt.Errorf("query cookies: %w", err) + } + defer rows.Close() + + var out []cookieRow + for rows.Next() { + var row cookieRow + if err := rows.Scan(&row.host, &row.name, &row.encrypted, &row.plainValue); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + +// UpdateSessionCookies rewrites auth_data.cookies in session.json atomically, +// preserving every other field. +func UpdateSessionCookies(sessionPath string, cookies map[string]string) error { + raw, err := os.ReadFile(sessionPath) + if err != nil { + return fmt.Errorf("read session: %w", err) + } + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + return fmt.Errorf("parse session: %w", err) + } + authData, ok := data["auth_data"].(map[string]any) + if !ok { + authData = map[string]any{} + data["auth_data"] = authData + } + authData["cookies"] = cookies + + updated, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("encode session: %w", err) + } + tmp := sessionPath + ".tmp" + if err := os.WriteFile(tmp, append(updated, '\n'), 0o600); err != nil { + return fmt.Errorf("write session: %w", err) + } + return os.Rename(tmp, sessionPath) +} diff --git a/internal/googlecookies/googlecookies_test.go b/internal/googlecookies/googlecookies_test.go new file mode 100644 index 0000000..c6053c8 --- /dev/null +++ b/internal/googlecookies/googlecookies_test.go @@ -0,0 +1,181 @@ +package googlecookies + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "database/sql" + "encoding/json" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +type dbCookie struct { + host string + name string + encrypted []byte +} + +// writeCookieDB builds a minimal Chrome-shaped cookies SQLite file. +func writeCookieDB(t *testing.T, path string, cookies []dbCookie) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open cookie db: %v", err) + } + defer db.Close() + if _, err := db.Exec(`create table cookies (host_key text, name text, encrypted_value blob, value text)`); err != nil { + t.Fatalf("create table: %v", err) + } + for _, c := range cookies { + if _, err := db.Exec( + `insert into cookies (host_key, name, encrypted_value, value) values (?, ?, ?, '')`, + c.host, c.name, c.encrypted, + ); err != nil { + t.Fatalf("insert cookie: %v", err) + } + } +} + +// encryptCookie mirrors Chrome's v10 scheme so DecryptCookie can be tested +// without a live Chrome profile: AES-128-CBC, IV of 16 spaces, PKCS7 padding, +// optional SHA256(host) plaintext prefix (Chrome 130+). +func encryptCookie(t *testing.T, value, host string, key []byte, withHostHash bool) []byte { + t.Helper() + plain := []byte(value) + if withHostHash { + h := sha256.Sum256([]byte(host)) + plain = append(h[:], plain...) + } + padLen := aes.BlockSize - len(plain)%aes.BlockSize + for i := 0; i < padLen; i++ { + plain = append(plain, byte(padLen)) + } + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("new cipher: %v", err) + } + out := make([]byte, len(plain)) + cipher.NewCBCEncrypter(block, []byte(" ")).CryptBlocks(out, plain) + return append([]byte("v10"), out...) +} + +func TestDecryptCookieRoundTrip(t *testing.T) { + key := DeriveKey([]byte("test-secret"), 1003) + for _, withHash := range []bool{false, true} { + enc := encryptCookie(t, "cookie-value-123", ".google.com", key, withHash) + got, err := DecryptCookie(enc, ".google.com", key) + if err != nil { + t.Fatalf("DecryptCookie(withHash=%v): %v", withHash, err) + } + if got != "cookie-value-123" { + t.Fatalf("DecryptCookie(withHash=%v) = %q, want %q", withHash, got, "cookie-value-123") + } + } +} + +func TestDecryptCookieIgnoresUnencrypted(t *testing.T) { + key := DeriveKey([]byte("test-secret"), 1003) + got, err := DecryptCookie([]byte("plain"), ".google.com", key) + if err != nil { + t.Fatalf("DecryptCookie() unexpected error: %v", err) + } + if got != "" { + t.Fatalf("DecryptCookie() = %q, want empty for non-v10 value", got) + } +} + +func TestUpdateSessionCookiesPreservesOtherFields(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.json") + original := map[string]any{ + "phone_id": "abc123", + "auth_data": map[string]any{ + "tachyon_token": "keep-me", + "cookies": map[string]any{"OLD": "stale"}, + }, + } + raw, _ := json.Marshal(original) + if err := os.WriteFile(sessionPath, raw, 0o600); err != nil { + t.Fatalf("write session: %v", err) + } + + newCookies := map[string]string{"SID": "fresh", "OSID": "fresh2"} + if err := UpdateSessionCookies(sessionPath, newCookies); err != nil { + t.Fatalf("UpdateSessionCookies(): %v", err) + } + + updatedRaw, err := os.ReadFile(sessionPath) + if err != nil { + t.Fatalf("read updated session: %v", err) + } + var updated map[string]any + if err := json.Unmarshal(updatedRaw, &updated); err != nil { + t.Fatalf("parse updated session: %v", err) + } + if updated["phone_id"] != "abc123" { + t.Fatalf("phone_id lost: %v", updated["phone_id"]) + } + authData := updated["auth_data"].(map[string]any) + if authData["tachyon_token"] != "keep-me" { + t.Fatalf("tachyon_token lost: %v", authData["tachyon_token"]) + } + cookies := authData["cookies"].(map[string]any) + if cookies["SID"] != "fresh" || cookies["OSID"] != "fresh2" { + t.Fatalf("cookies not replaced: %v", cookies) + } + if _, exists := cookies["OLD"]; exists { + t.Fatalf("stale cookie survived replacement") + } +} + +func TestLoadChromeCookiesRequiresAllSessionCookies(t *testing.T) { + // A profile whose cookie DB has only a subset of the required cookies must + // fail loudly rather than write a half-valid session that 401s anyway. + dir := t.TempDir() + profile := filepath.Join(dir, "Default") + if err := os.MkdirAll(filepath.Join(profile, "Network"), 0o700); err != nil { + t.Fatalf("mkdir profile: %v", err) + } + key := DeriveKey([]byte("secret"), 1003) + writeCookieDB(t, filepath.Join(profile, "Network", "Cookies"), []dbCookie{ + {".google.com", "SID", encryptCookie(t, "v", ".google.com", key, false)}, + // OSID/HSID/SSID/APISID/SAPISID intentionally absent. + }) + + _, err := LoadChromeCookies(profile, []byte("secret")) + if err == nil { + t.Fatal("expected error for missing required cookies, got nil") + } +} + +func TestLoadChromeCookiesHappyPath(t *testing.T) { + dir := t.TempDir() + profile := filepath.Join(dir, "Default") + if err := os.MkdirAll(filepath.Join(profile, "Network"), 0o700); err != nil { + t.Fatalf("mkdir profile: %v", err) + } + secret := []byte("secret") + key := DeriveKey(secret, 1003) + rows := make([]dbCookie, 0, len(requiredCookies)) + for _, req := range requiredCookies { + rows = append(rows, dbCookie{req.host, req.name, encryptCookie(t, "val-"+req.name, req.host, key, true)}) + } + // A duplicate SID on a lower-priority host must not shadow the .google.com one. + rows = append(rows, dbCookie{"accounts.google.com", "SID", encryptCookie(t, "wrong", "accounts.google.com", key, true)}) + writeCookieDB(t, filepath.Join(profile, "Network", "Cookies"), rows) + + got, err := LoadChromeCookies(profile, secret) + if err != nil { + t.Fatalf("LoadChromeCookies(): %v", err) + } + if got["SID"] != "val-SID" { + t.Fatalf("SID = %q, want %q (host priority not applied)", got["SID"], "val-SID") + } + if got["OSID"] != "val-OSID" { + t.Fatalf("OSID = %q, want %q", got["OSID"], "val-OSID") + } +} diff --git a/internal/googlecookies/secret_darwin.go b/internal/googlecookies/secret_darwin.go new file mode 100644 index 0000000..7771797 --- /dev/null +++ b/internal/googlecookies/secret_darwin.go @@ -0,0 +1,52 @@ +//go:build darwin + +package googlecookies + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// NativeSupported reports whether this build can refresh cookies without an +// external script: macOS with a readable Chrome profile present. The first +// keychain read may show a one-time "OpenMessage wants to access Chrome Safe +// Storage" prompt; Always Allow persists it. +func NativeSupported() bool { + profile := DefaultChromeProfile() + if profile == "" { + return false + } + for _, c := range []string{ + filepath.Join(profile, "Network", "Cookies"), + filepath.Join(profile, "Cookies"), + } { + if _, err := os.Stat(c); err == nil { + return true + } + } + return false +} + +func defaultChromeProfileDir(home string) string { + return filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default") +} + +// chromeSafeStorageSecret reads Chrome's cookie-encryption password from the +// login keychain. The value itself is never logged. +func chromeSafeStorageSecret(ctx context.Context) ([]byte, error) { + out, err := exec.CommandContext(ctx, + "/usr/bin/security", "find-generic-password", "-w", "-s", "Chrome Safe Storage", + ).Output() + if err != nil { + return nil, fmt.Errorf("read Chrome Safe Storage from keychain: %w", err) + } + secret := bytes.TrimRight(out, "\n") + if len(secret) == 0 { + return nil, fmt.Errorf("Chrome Safe Storage key was empty") + } + return secret, nil +} diff --git a/internal/googlecookies/secret_other.go b/internal/googlecookies/secret_other.go new file mode 100644 index 0000000..6ff0c75 --- /dev/null +++ b/internal/googlecookies/secret_other.go @@ -0,0 +1,23 @@ +//go:build !darwin + +package googlecookies + +import ( + "context" + "fmt" + "path/filepath" +) + +// NativeSupported is darwin-only for now; Linux installs configure +// OPENMESSAGE_COOKIE_REFRESH_SCRIPT (see scripts/) instead. +func NativeSupported() bool { + return false +} + +func defaultChromeProfileDir(home string) string { + return filepath.Join(home, ".config", "google-chrome", "Default") +} + +func chromeSafeStorageSecret(ctx context.Context) ([]byte, error) { + return nil, fmt.Errorf("native Chrome cookie refresh is not supported on this platform") +} diff --git a/macos/OpenMessage/Sources/NotificationManager.swift b/macos/OpenMessage/Sources/NotificationManager.swift index f13edf3..0d20d62 100644 --- a/macos/OpenMessage/Sources/NotificationManager.swift +++ b/macos/OpenMessage/Sources/NotificationManager.swift @@ -41,6 +41,20 @@ final class NotificationManager: NSObject, ObservableObject, UNUserNotificationC } } + private struct PlatformStatus: Decodable { + struct Google: Decodable { + let connected: Bool? + let paired: Bool? + let needs_repair: Bool? + } + struct WhatsApp: Decodable { + let connected: Bool? + let paired: Bool? + } + let google: Google? + let whatsapp: WhatsApp? + } + private let logger = Logger(subsystem: "com.openmessage.app", category: "Notifications") private let defaults = UserDefaults.standard private let baseURL: URL @@ -49,6 +63,11 @@ final class NotificationManager: NSObject, ObservableObject, UNUserNotificationC private var seenMessageIDs = Set() private var seenMessageOrder: [String] = [] private var preferenceEnabled: Bool + // Platform keys currently in a notified "needs attention" state, so we alert + // on the rising edge only (once per outage) and re-arm after recovery. The + // failure this guards against is a platform silently dying for days — the + // 15-day Google staleness that motivated this. + private var alertedBrokenPlatforms = Set() private let recentConversationLimit = 50 private let recentMessageLimit = 10 @@ -238,6 +257,9 @@ final class NotificationManager: NSObject, ObservableObject, UNUserNotificationC // Catch up after any reconnect or transport failure. await scanRecentConversations(notify: true) + // Safety net: also re-check platform health here, so a needs-repair + // flag that flips while our SSE stream is down isn't missed. + await checkPlatformHealth() try? await Task.sleep(for: reconnectDelay) } } @@ -287,11 +309,72 @@ final class NotificationManager: NSObject, ObservableObject, UNUserNotificationC switch eventName { case "messages", "conversations": await scanRecentConversations(notify: true) + case "status": + await checkPlatformHealth() default: break } } + /// Alerts the user once when a messaging platform enters a state that needs + /// manual intervention (Google Messages flagged for re-pair, or WhatsApp + /// logged out) and won't self-heal. Re-arms after the platform recovers so + /// a later outage alerts again. Without this a platform can go dark for days + /// with no signal beyond a subtle in-app badge. + private func checkPlatformHealth() async { + guard preferenceEnabled else { return } + let status: PlatformStatus + do { + let (data, _) = try await URLSession.shared.data(from: apiURL(pathComponents: ["api", "status"])) + status = try JSONDecoder().decode(PlatformStatus.self, from: data) + } catch { + logger.debug("Health check fetch error: \(error)") + return + } + + evaluatePlatformHealth( + key: "google", + broken: (status.google?.paired ?? false) && (status.google?.needs_repair ?? false), + title: "Google Messages needs attention", + body: "SMS/RCS sync stopped and couldn't auto-recover. Open OpenMessage to re-pair." + ) + evaluatePlatformHealth( + key: "whatsapp", + broken: !(status.whatsapp?.paired ?? true), + title: "WhatsApp needs attention", + body: "WhatsApp was logged out. Open OpenMessage to scan the QR code and reconnect." + ) + } + + private func evaluatePlatformHealth(key: String, broken: Bool, title: String, body: String) { + if broken { + guard !alertedBrokenPlatforms.contains(key) else { return } + alertedBrokenPlatforms.insert(key) + sendHealthNotification(key: key, title: title, body: body) + } else { + alertedBrokenPlatforms.remove(key) + } + } + + private func sendHealthNotification(key: String, title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = ["healthAlert": key] + + let request = UNNotificationRequest( + identifier: "health-\(key)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + self.logger.error("Failed to send health notification: \(error)") + } + } + } + private func scanRecentConversations(notify: Bool) async { do { let conversations = try await fetchRecentConversations() diff --git a/scripts/refresh-google-session-cookies-macos.py b/scripts/refresh-google-session-cookies-macos.py new file mode 100755 index 0000000..2b8611f --- /dev/null +++ b/scripts/refresh-google-session-cookies-macos.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Refresh OpenMessage Google account cookies from a local Chrome profile (macOS). + +macOS sibling of refresh-google-session-cookies-linux.py, with the same CLI +contract (--quiet, --no-backup) so it can be used as +OPENMESSAGE_COOKIE_REFRESH_SCRIPT by the serve reconnect watchdog. + +Differences from the Linux script: +- The Chrome Safe Storage secret comes from the macOS login keychain + (`security find-generic-password`), not KWallet. +- AES-128-CBC decryption shells out to /usr/bin/openssl, so the script needs + no third-party Python packages and can ship inside the app bundle. +- The cookie DB is copied (with its WAL) to a temp dir before reading, because + a running Chrome holds the live DB and an immutable read can miss the most + recent cookie rotation — Google session cookies rotate roughly every 30 + minutes, so staleness matters here. + +It updates only auth_data.cookies in OpenMessage's session.json and never +prints cookie values. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +REQUIRED_COOKIES = { + ("messages.google.com", "OSID"), + (".google.com", "SID"), + (".google.com", "HSID"), + (".google.com", "SSID"), + (".google.com", "APISID"), + (".google.com", "SAPISID"), +} +HOST_PRIORITY = { + "messages.google.com": 0, + ".google.com": 1, + "accounts.google.com": 2, +} + + +def parse_args() -> argparse.Namespace: + home = Path.home() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--profile", + default=os.environ.get( + "OPENMESSAGE_CHROME_PROFILE", + str(home / "Library/Application Support/Google/Chrome/Default"), + ), + help="Chrome profile directory containing Network/Cookies", + ) + parser.add_argument( + "--session", + default=os.environ.get( + "OPENMESSAGE_SESSION_PATH", + str(home / "Library/Application Support/OpenMessage/session.json"), + ), + help="OpenMessage session.json path", + ) + parser.add_argument("--quiet", action="store_true", help="only print errors") + parser.add_argument("--no-backup", action="store_true", help="do not write a session backup") + return parser.parse_args() + + +def chrome_safe_storage_secret() -> bytes: + value = subprocess.check_output( + ["/usr/bin/security", "find-generic-password", "-w", "-s", "Chrome Safe Storage"], + text=False, + ).rstrip(b"\n") + if not value: + raise RuntimeError("Chrome Safe Storage key was empty") + return value + + +def derive_key(secret: bytes, iterations: int) -> bytes: + return hashlib.pbkdf2_hmac("sha1", secret, b"saltysalt", iterations, 16) + + +def aes_cbc_decrypt(ciphertext: bytes, key: bytes) -> bytes: + """AES-128-CBC decrypt with IV of 16 spaces via /usr/bin/openssl (-nopad; + PKCS7 padding is stripped by the caller so we can validate it).""" + result = subprocess.run( + [ + "/usr/bin/openssl", "enc", "-d", "-aes-128-cbc", "-nopad", + "-K", key.hex(), + "-iv", (b" " * 16).hex(), + ], + input=ciphertext, + capture_output=True, + check=True, + ) + return result.stdout + + +def decrypt_cookie(encrypted_value: bytes, host: str, key: bytes) -> str | None: + if not encrypted_value: + return None + encrypted_value = bytes(encrypted_value) + if not (encrypted_value.startswith(b"v10") or encrypted_value.startswith(b"v11")): + return None + + padded = aes_cbc_decrypt(encrypted_value[3:], key) + if not padded: + raise ValueError("empty decryption result") + pad_len = padded[-1] + if pad_len < 1 or pad_len > 16: + raise ValueError("invalid cookie padding") + + plain = padded[:-pad_len] + host_hash = hashlib.sha256(host.encode()).digest() + if plain.startswith(host_hash): + plain = plain[32:] + return plain.decode("utf-8") + + +def snapshot_cookie_db(profile: Path) -> tuple[Path, tempfile.TemporaryDirectory]: + """Copy the cookie DB (+WAL/SHM) so we read the freshest values even while + Chrome holds the live file. Returns the copied DB path and the tempdir + handle keeping it alive.""" + candidates = [profile / "Network" / "Cookies", profile / "Cookies"] + src = next((p for p in candidates if p.exists()), None) + if src is None: + raise FileNotFoundError(f"Chrome cookie DB not found under: {profile}") + + tmpdir = tempfile.TemporaryDirectory(prefix="om-cookie-refresh-") + dst = Path(tmpdir.name) / "Cookies" + shutil.copy2(src, dst) + for suffix in ("-wal", "-shm"): + side = src.with_name(src.name + suffix) + if side.exists(): + shutil.copy2(side, dst.with_name(dst.name + suffix)) + os.chmod(dst, 0o600) + return dst, tmpdir + + +def load_chrome_cookies(profile: Path, secret: bytes) -> tuple[dict[str, str], int, int, int]: + cookie_db, tmpdir = snapshot_cookie_db(profile) + try: + # Opening the copy read-write replays the WAL into the main DB, so + # recently rotated cookies are visible. + rows = sqlite3.connect(cookie_db).execute( + """ + select host_key, name, encrypted_value, value + from cookies + where host_key in ('.google.com','messages.google.com','accounts.google.com') + or host_key like '%.google.com' + """ + ).fetchall() + finally: + tmpdir.cleanup() + + best: tuple[tuple[int, int, int], int, dict[str, tuple[str, str]], int, int, set[tuple[str, str]]] | None = None + for iterations in (1003, 1): + key = derive_key(secret, iterations) + values: dict[str, tuple[str, str]] = {} + ok = 0 + failed = 0 + for host, name, encrypted_value, plain_value in rows: + value = plain_value or None + if not value and encrypted_value: + try: + value = decrypt_cookie(encrypted_value, host, key) + except Exception: + failed += 1 + continue + if value is None: + continue + ok += 1 + previous = values.get(name) + if previous is None or HOST_PRIORITY.get(host, 10) < HOST_PRIORITY.get(previous[0], 10): + values[name] = (host, value) + + present = { + (host, name) + for name, (host, _value) in values.items() + if (host, name) in REQUIRED_COOKIES + } + score = (len(present), ok, -failed) + if best is None or score > best[0]: + best = (score, iterations, values, ok, failed, present) + + if best is None: + raise RuntimeError("no Google cookies found in Chrome profile") + + _score, iterations, values, ok, failed, present = best + missing = sorted(REQUIRED_COOKIES - present) + if missing: + missing_text = ", ".join(f"{host}:{name}" for host, name in missing) + raise RuntimeError(f"missing required cookies: {missing_text}") + + return {name: value for name, (_host, value) in values.items()}, iterations, ok, failed + + +def update_session(session_path: Path, cookies: dict[str, str], backup: bool) -> Path | None: + if not session_path.exists(): + raise FileNotFoundError(f"OpenMessage session not found: {session_path}") + + backup_path = None + if backup: + backup_path = session_path.with_name(f"session.json.bak-{time.strftime('%Y%m%d-%H%M%S')}") + shutil.copy2(session_path, backup_path) + + data = json.loads(session_path.read_text()) + data.setdefault("auth_data", {})["cookies"] = cookies + + tmp = session_path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n") + os.chmod(tmp, 0o600) + tmp.replace(session_path) + return backup_path + + +def main() -> int: + args = parse_args() + try: + secret = chrome_safe_storage_secret() + cookies, iterations, decrypted, failed = load_chrome_cookies(Path(args.profile), secret) + backup_path = update_session(Path(args.session), cookies, backup=not args.no_backup) + except Exception as exc: + print(f"refresh_google_session_cookies: {exc}", file=sys.stderr) + return 1 + + if not args.quiet: + print("session_cookie_refresh_ok") + if backup_path: + print(f"backup: {backup_path}") + print(f"cookies_written: {len(cookies)}") + print(f"decrypt_iterations: {iterations}") + print(f"decrypted_cookies: {decrypted}") + print(f"decrypt_failures: {failed}") + print("required_present: APISID,HSID,OSID,SAPISID,SID,SSID") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())