Files
Commilitia-Drop/desktop/platform/session.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

259 lines
8.7 KiB
Go

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
}