mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-05 23:07:39 +02:00
refactor(credstore): embed credential store and drop sdk-go dependency
- Embed the minimal credstore subset used by tea (SecureStore, EncryptedFileStore, KeyringStore, FileStore) as modules/credstore so external SDK renames can no longer break the build - Keep the on-disk format fully compatible: AES-256-GCM values with the v1: prefix, credentials.json / credentials.json.enc paths, and the Token JSON field names are unchanged, verified by a ciphertext fixture generated with sdk-go v1.1.0 - Store the keyring master key under a tea-owned account name - Reuse the existing kernel-level filelock module instead of the upstream lockfile protocol, removing a stale-lock race - Cover roundtrip, keyring-unavailable fallback, and fixture decryption with tests using a mocked keyring - Remove github.com/go-signet/sdk-go and promote github.com/zalando/go-keyring to a direct dependency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// masterKeySize is the AES-256 key length in bytes.
|
||||
const masterKeySize = 32
|
||||
|
||||
// masterKeyUser is the keyring account name under which the master key is
|
||||
// stored. It must never change: installations hold their master key under
|
||||
// this exact name.
|
||||
const masterKeyUser = "__tea_master_key__"
|
||||
|
||||
// sealedPrefix versions the on-disk encrypted value format so a future
|
||||
// algorithm change can be detected instead of guessed at.
|
||||
const sealedPrefix = "v1:"
|
||||
|
||||
// masterKey manages a per-service AES-256 key held in the OS keyring and
|
||||
// caches the derived AEAD in memory. See EncryptedFileStore for why only the
|
||||
// key lives in the keyring.
|
||||
type masterKey struct {
|
||||
store *KeyringStore[string]
|
||||
|
||||
mu sync.Mutex
|
||||
aead cipher.AEAD // cached after the first successful load or create
|
||||
}
|
||||
|
||||
// loadLocked returns the cached or keyring-held AEAD. It returns ErrNotFound
|
||||
// unwrapped when no key exists yet so callers can distinguish "no key" from
|
||||
// "keyring unavailable". m.mu must be held.
|
||||
func (m *masterKey) loadLocked() (cipher.AEAD, error) {
|
||||
if m.aead != nil {
|
||||
return m.aead, nil
|
||||
}
|
||||
|
||||
encoded, err := m.store.Load(masterKeyUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// e.g. Linux headless without Secret Service, or keyring locked.
|
||||
return nil, fmt.Errorf("failed to read master key: %w", err)
|
||||
}
|
||||
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encoded)
|
||||
if decodeErr != nil || len(key) != masterKeySize {
|
||||
return nil, errors.New("corrupted master key in keyring")
|
||||
}
|
||||
return m.cacheLocked(key)
|
||||
}
|
||||
|
||||
// cacheLocked builds the AEAD for key and caches it. m.mu must be held.
|
||||
func (m *masterKey) cacheLocked(key []byte) (cipher.AEAD, error) {
|
||||
aead, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.aead = aead
|
||||
return aead, nil
|
||||
}
|
||||
|
||||
// load returns the AEAD without ever creating a key, so decryption paths
|
||||
// cannot mint a key that has no chance of opening existing ciphertext.
|
||||
func (m *masterKey) load() (cipher.AEAD, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.loadLocked()
|
||||
}
|
||||
|
||||
// get returns the AEAD, generating and persisting a new key on first use.
|
||||
func (m *masterKey) get() (cipher.AEAD, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
aead, err := m.loadLocked()
|
||||
if err == nil {
|
||||
return aead, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First use: generate and persist a new key.
|
||||
key := make([]byte, masterKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate master key: %w", err)
|
||||
}
|
||||
if err := m.store.Save(masterKeyUser, base64.StdEncoding.EncodeToString(key)); err != nil {
|
||||
return nil, fmt.Errorf("failed to store master key: %w", err)
|
||||
}
|
||||
return m.cacheLocked(key)
|
||||
}
|
||||
|
||||
// available reports whether the keyring can serve the master key without
|
||||
// creating one: a cached or stored valid key counts, and so does a clean
|
||||
// not-found (the key is generated lazily on first Save). A corrupted key or
|
||||
// an unreachable keyring does not.
|
||||
func (m *masterKey) available() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, err := m.loadLocked()
|
||||
return err == nil || errors.Is(err, ErrNotFound)
|
||||
}
|
||||
|
||||
// newGCM creates an AES-256-GCM AEAD for the given key.
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
return gcm, nil
|
||||
}
|
||||
|
||||
// sealValue encrypts plaintext with AES-256-GCM and returns
|
||||
// "v1:" + base64(nonce || ciphertext).
|
||||
func sealValue(aead cipher.AEAD, plaintext string) (string, error) {
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
// Seal appends ciphertext+tag to nonce, so the stored value is self-contained.
|
||||
sealed := aead.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return sealedPrefix + base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// openValue decrypts a value produced by sealValue.
|
||||
func openValue(aead cipher.AEAD, encoded string) (string, error) {
|
||||
rest, ok := strings.CutPrefix(encoded, sealedPrefix)
|
||||
if !ok {
|
||||
return "", errors.New("unrecognized encrypted value format")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(rest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode encrypted value: %w", err)
|
||||
}
|
||||
if len(data) < aead.NonceSize() {
|
||||
return "", errors.New("encrypted value too short")
|
||||
}
|
||||
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
|
||||
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
// Wrong key or tampered value — GCM authentication failed.
|
||||
return "", fmt.Errorf("failed to decrypt value (key mismatch or tampering): %w", err)
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// encryptedCodec wraps an inner codec with AES-256-GCM encryption using a
|
||||
// keyring-held master key.
|
||||
type encryptedCodec[T any] struct {
|
||||
inner Codec[T]
|
||||
key *masterKey
|
||||
}
|
||||
|
||||
// Encode encodes v with the inner codec and encrypts the result.
|
||||
func (c encryptedCodec[T]) Encode(v T) (string, error) {
|
||||
aead, err := c.key.get()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := c.inner.Encode(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sealValue(aead, plaintext)
|
||||
}
|
||||
|
||||
// Decode decrypts s and decodes the plaintext with the inner codec.
|
||||
func (c encryptedCodec[T]) Decode(s string) (T, error) {
|
||||
var zero T
|
||||
aead, err := c.key.load()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Deliberately not wrapping ErrNotFound: the value exists but
|
||||
// cannot be decrypted, which must not read as "no data stored".
|
||||
return zero, errors.New("cannot decrypt stored value: master key not found in keyring")
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
plaintext, err := openValue(aead, s)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return c.inner.Decode(plaintext)
|
||||
}
|
||||
|
||||
// EncryptedFileStore stores values encrypted with AES-256-GCM in a JSON file,
|
||||
// keeping only the 32-byte master key in the OS keyring. The keyring payload
|
||||
// is a constant 44 bytes (base64) regardless of value size, so it never hits
|
||||
// the Windows Credential Manager 2560-byte blob limit or the macOS/Linux
|
||||
// keyring item size limits. The values themselves (which can be several KB
|
||||
// for tokens with groups claims) are encrypted into a file with 0600
|
||||
// permissions, file locking, and atomic writes.
|
||||
//
|
||||
// EncryptedFileStore implements Store[T] and Prober.
|
||||
type EncryptedFileStore[T any] struct {
|
||||
file *FileStore[T]
|
||||
key *masterKey
|
||||
}
|
||||
|
||||
// NewEncryptedFileStore creates an EncryptedFileStore. serviceName is the
|
||||
// keyring service under which the master key is stored; filePath is the
|
||||
// encrypted data file. Panics if codec is nil.
|
||||
func NewEncryptedFileStore[T any](
|
||||
serviceName, filePath string,
|
||||
codec Codec[T],
|
||||
) *EncryptedFileStore[T] {
|
||||
if codec == nil {
|
||||
panic("credstore: NewEncryptedFileStore called with nil codec")
|
||||
}
|
||||
key := &masterKey{store: NewStringKeyringStore(serviceName)}
|
||||
return &EncryptedFileStore[T]{
|
||||
file: NewFileStore[T](filePath, encryptedCodec[T]{inner: codec, key: key}),
|
||||
key: key,
|
||||
}
|
||||
}
|
||||
|
||||
// Probe reports whether the OS keyring can serve the master key. It is
|
||||
// read-only: the key itself is generated lazily on the first Save. Once the
|
||||
// key is cached in memory, Probe keeps reporting true even if the keyring
|
||||
// later becomes unavailable, because the store remains operational with the
|
||||
// cached key.
|
||||
func (e *EncryptedFileStore[T]) Probe() bool {
|
||||
return e.key.available()
|
||||
}
|
||||
|
||||
// Load loads and decrypts data for the given client ID.
|
||||
func (e *EncryptedFileStore[T]) Load(clientID string) (T, error) {
|
||||
return e.file.Load(clientID)
|
||||
}
|
||||
|
||||
// Save encrypts and saves data for the given client ID.
|
||||
func (e *EncryptedFileStore[T]) Save(clientID string, data T) error {
|
||||
return e.file.Save(clientID, data)
|
||||
}
|
||||
|
||||
// Delete removes data for the given client ID from the file.
|
||||
func (e *EncryptedFileStore[T]) Delete(clientID string) error {
|
||||
return e.file.Delete(clientID)
|
||||
}
|
||||
|
||||
// String returns a description of this store.
|
||||
func (e *EncryptedFileStore[T]) String() string {
|
||||
return "encrypted-file: " + e.file.filePath
|
||||
}
|
||||
Reference in New Issue
Block a user