server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package credentials
|
||||
|
||||
// Package credentials stores platform secrets outside the SQL account model.
|
||||
// Files are encrypted with AES-256-GCM and keyed by workspace/account IDs so a
|
||||
// leaked account row or browser event never contains cookies.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var errInvalidID = errors.New("workspace and account IDs must be positive")
|
||||
|
||||
// Store is a process-safe encrypted credential store.
|
||||
type Store struct {
|
||||
root string
|
||||
key []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Open creates or loads a 32-byte master key in root. The key file is never
|
||||
// returned by an API and is permission-restricted to the current user.
|
||||
func Open(root string) (*Store, error) {
|
||||
if root == "" {
|
||||
return nil, errors.New("credential directory is empty")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPath := filepath.Join(root, "master.key")
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
key = make([]byte, 32)
|
||||
if _, err = io.ReadFull(rand.Reader, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = writePrivate(keyPath, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("credential master key must be 32 bytes")
|
||||
}
|
||||
return &Store{root: root, key: append([]byte(nil), key...)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) path(workspaceID, accountID uint64) (string, error) {
|
||||
if workspaceID == 0 || accountID == 0 {
|
||||
return "", errInvalidID
|
||||
}
|
||||
dir := filepath.Join(s.root, strconv.FormatUint(workspaceID, 10))
|
||||
return filepath.Join(dir, strconv.FormatUint(accountID, 10)+".enc"), nil
|
||||
}
|
||||
|
||||
// Save encrypts value and atomically replaces the account file.
|
||||
func (s *Store) Save(workspaceID, accountID uint64, value []byte) error {
|
||||
path, err := s.path(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, value, nil)
|
||||
payload := struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Data string `json:"data"`
|
||||
}{base64.RawStdEncoding.EncodeToString(nonce), base64.RawStdEncoding.EncodeToString(ciphertext)}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".credential-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err = tmp.Chmod(0o600); err == nil {
|
||||
_, err = tmp.Write(raw)
|
||||
}
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
// Load decrypts credentials for one workspace/account pair.
|
||||
func (s *Store) Load(workspaceID, accountID uint64) ([]byte, error) {
|
||||
path, err := s.path(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
raw, err := os.ReadFile(path)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, fmt.Errorf("credential envelope: %w", err)
|
||||
}
|
||||
nonce, err := base64.RawStdEncoding.DecodeString(payload.Nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credential nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.RawStdEncoding.DecodeString(payload.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credential data: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nonce) != gcm.NonceSize() {
|
||||
return nil, errors.New("credential nonce has invalid size")
|
||||
}
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("credential authentication failed")
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// Delete removes credentials. Missing files are treated as success.
|
||||
func (s *Store) Delete(workspaceID, accountID uint64) error {
|
||||
path, err := s.path(workspaceID, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err = os.Remove(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func writePrivate(path string, value []byte) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".master-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err = tmp.Chmod(0o600); err == nil {
|
||||
_, err = tmp.Write(value)
|
||||
}
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreRoundTripAndIsolation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := []byte(`{"cookies":"SESSDATA=secret"}`)
|
||||
if err = s.Save(7, 11, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Load(7, 11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(secret) {
|
||||
t.Fatalf("round trip mismatch: %q", got)
|
||||
}
|
||||
if _, err = s.Load(8, 11); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace isolation failed: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "7", "11.enc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) == string(secret) {
|
||||
t.Fatal("credential file is plaintext")
|
||||
}
|
||||
if err = s.Delete(7, 11); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsInvalidIDs(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.Save(0, 1, []byte("x")); err == nil {
|
||||
t.Fatal("expected invalid ID error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user