f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
259 lines
8.7 KiB
Go
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
|
|
}
|