package platform import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" "errors" "io" "log/slog" "os" "path/filepath" keyring "github.com/zalando/go-keyring" ) // Session persistence lives in a Go-owned file, NOT WebView storage: Wails serves // the UI from the wails:// custom scheme, and WKWebView does not persist // localStorage / sessionStorage for custom-scheme origins, so any in-page store // is wiped on every restart. The persisted session is injected back into the // page at load time (see SessionInjectMiddleware) so it's available synchronously // without a binding round-trip. // // Credential policy (consistent with the browser): the refresh_token is the // long-lived secret and is never exposed to the WebView/JS context. It lives only // in the Go process and at rest; the WebView only ever receives the short-lived // access_token (via SessionView). Refresh is Go-internal — the WebView calls // Refresh() with no argument and Go uses its own copy. // // At rest the refresh_token is encrypted, not plaintext (R2). It is the one // credential that lets a holder mint fresh access tokens indefinitely, so // same-user malware must not be able to read it off disk. We can't store it in // the OS secret store directly: Casdoor issues JWT refresh_tokens that run to // ~15 KB, far past go-keyring's per-item limits (macOS ~3 KB command, Windows // 2560 B). Instead we keep a 32-byte AES key in the secret store (tiny, no size // issue on any platform) and write the AES-256-GCM ciphertext to the 0600 file. // Reading the file yields only ciphertext; decrypting it needs the key, which is // protected exactly as a directly-stored token would be — same posture, no size // cap. The key goes through go-keyring's system tooling rather than this app's // code signature, so an ad-hoc / per-build re-sign doesn't invalidate it. // // The short-lived access_token and display identity stay as plaintext in the // file: the access_token is already handed to the WebView and expires fast, so // it is not the asset R2 protects. const ( keyringService = "cdrop" // keyringKeyAccount holds the 32-byte AES key (base64) that encrypts the // refresh_token at rest — NOT the token itself. keyringKeyAccount = "session_key" ) // persistedSession is the on-disk shape. RefreshTokenEnc holds // base64(nonce||ciphertext) under AES-256-GCM. RefreshToken is only ever set on // a legacy file (written before R2) or as a degraded fallback when the secret // store is unavailable; the happy path leaves it empty. type persistedSession struct { AccessToken string `json:"access_token"` RefreshTokenEnc string `json:"refresh_token_enc,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` ExpiresIn int `json:"expires_in"` User UserInfo `json:"user"` } // sessionPath is ~/Library/Application Support/cdrop/session.json on macOS. func sessionPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", err } return filepath.Join(dir, "cdrop", "session.json"), nil } // loadOrCreateKey returns the AES-256 key from the OS secret store, minting and // storing a fresh random one on first use. func loadOrCreateKey() ([]byte, error) { if enc, err := keyring.Get(keyringService, keyringKeyAccount); err == nil { if key, derr := base64.StdEncoding.DecodeString(enc); derr == nil && len(key) == 32 { return key, nil } // A corrupt / wrong-sized value is unusable; fall through and regenerate. // (Any ciphertext encrypted under the old value becomes undecryptable — // the user re-logs in, which is acceptable for a corrupt store.) } else if !errors.Is(err, keyring.ErrNotFound) { return nil, err } key := make([]byte, 32) if _, err := rand.Read(key); err != nil { return nil, err } if err := keyring.Set(keyringService, keyringKeyAccount, base64.StdEncoding.EncodeToString(key)); err != nil { return nil, err } return key, nil } func newGCM(key []byte) (cipher.AEAD, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) } // encryptToken seals the refresh_token under the secret-store key, returning // base64(nonce||ciphertext). func encryptToken(plaintext string) (string, error) { key, err := loadOrCreateKey() if err != nil { return "", err } gcm, err := newGCM(key) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) return base64.StdEncoding.EncodeToString(sealed), nil } // decryptToken opens a refresh_token ciphertext using the secret-store key. It // never mints a key: a missing key means the ciphertext is unrecoverable (the // caller falls back to requiring a fresh login). func decryptToken(enc string) (string, error) { keyB64, err := keyring.Get(keyringService, keyringKeyAccount) if err != nil { return "", err } key, err := base64.StdEncoding.DecodeString(keyB64) if err != nil || len(key) != 32 { return "", errors.New("session: malformed key in secret store") } raw, err := base64.StdEncoding.DecodeString(enc) if err != nil { return "", err } gcm, err := newGCM(key) if err != nil { return "", err } if len(raw) < gcm.NonceSize() { return "", errors.New("session: ciphertext too short") } nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", err } return string(plaintext), nil } // SaveSession persists the login session. The refresh_token is encrypted at rest // under a key held in the OS secret store; everything else (short-lived // access_token + identity) is plaintext in the 0600 file. If the secret store is // unavailable (e.g. a headless Linux box with no Secret Service), we fall back to // writing the refresh_token in plaintext so login still works — logging the // downgrade rather than failing auth outright. func SaveSession(res LoginResult) error { p, err := sessionPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { return err } rec := persistedSession{ AccessToken: res.AccessToken, ExpiresIn: res.ExpiresIn, User: res.User, } if res.RefreshToken != "" { if enc, err := encryptToken(res.RefreshToken); err == nil { rec.RefreshTokenEnc = enc } else { slog.Warn("cdrop: refresh_token kept in session file; OS secret store unavailable", "err", err) rec.RefreshToken = res.RefreshToken } } data, err := json.Marshal(rec) if err != nil { return err } return os.WriteFile(p, data, 0o600) } // LoadSession returns the persisted session, or nil when none is stored / the // file is unreadable or empty. The refresh_token is decrypted from the file // using the secret-store key. A legacy file that still embeds the plaintext // refresh_token (written before R2, or by the fallback above) is re-encrypted on // first load and stripped of its plaintext. func LoadSession() (*LoginResult, error) { p, err := sessionPath() if err != nil { return nil, err } data, err := os.ReadFile(p) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var rec persistedSession if err := json.Unmarshal(data, &rec); err != nil { return nil, err } if rec.AccessToken == "" { return nil, nil } res := &LoginResult{ AccessToken: rec.AccessToken, ExpiresIn: rec.ExpiresIn, User: rec.User, } switch { case rec.RefreshToken != "": // Plaintext on disk (legacy or fallback): keep it in memory and re-save, // which encrypts it and strips the plaintext (no-ops if the store is // still unavailable, leaving the file as-is). res.RefreshToken = rec.RefreshToken if err := SaveSession(*res); err != nil { slog.Warn("cdrop: refresh_token migration to secret store failed", "err", err) } case rec.RefreshTokenEnc != "": if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil { res.RefreshToken = rt } else { // Key gone / ciphertext corrupt: drop to an access-token-only session; // the app will require a fresh login once the access_token lapses. slog.Warn("cdrop: decrypt refresh_token failed; re-login will be required", "err", err) } } return res, nil } // ClearSession removes the persisted session (logout): both the file and the // encryption key held in the OS secret store. Absent entries are a no-op. func ClearSession() error { p, err := sessionPath() if err != nil { return err } if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { return err } if err := keyring.Delete(keyringService, keyringKeyAccount); err != nil && !errors.Is(err, keyring.ErrNotFound) { slog.Warn("cdrop: clear session key from secret store failed", "err", err) } return nil }