mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-05 23:07:39 +02:00
f6d939a8df
- 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>
260 lines
8.1 KiB
Go
260 lines
8.1 KiB
Go
// 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
|
|
}
|