mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-30 23:00:40 +02:00
Each isolated download runtime carried its own storage write mutex, so two concurrent downloads through the same extension could interleave writes to the shared storage file, the encrypted credentials file, or - worst - both generate different credential salts, making the loser's credentials undecryptable. Guard all three files with a process-wide per-path mutex and write via temp file + rename so readers never see a torn file. Groundwork for concurrent downloads.
554 lines
12 KiB
Go
554 lines
12 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
// Isolated per-download runtimes of the same extension share the storage,
|
|
// credentials, and salt files on disk, so writers must be serialized
|
|
// process-wide; the per-runtime mutexes only cover a single VM.
|
|
var extensionFileMus sync.Map // file path -> *sync.Mutex
|
|
|
|
func extensionFileMu(path string) *sync.Mutex {
|
|
mu, _ := extensionFileMus.LoadOrStore(path, &sync.Mutex{})
|
|
return mu.(*sync.Mutex)
|
|
}
|
|
|
|
// writeExtensionFileLocked writes data via a temp file + rename so a reader
|
|
// never observes a torn write. Callers must hold extensionFileMu(path).
|
|
func writeExtensionFileLocked(path string, data []byte) error {
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
const (
|
|
defaultStorageFlushDelay = 400 * time.Millisecond
|
|
storageFlushRetryDelay = 2 * time.Second
|
|
)
|
|
|
|
func (r *extensionRuntime) getStoragePath() string {
|
|
return filepath.Join(r.dataDir, "storage.json")
|
|
}
|
|
|
|
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
|
if len(src) == 0 {
|
|
return make(map[string]interface{})
|
|
}
|
|
dst := make(map[string]interface{}, len(src))
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func (r *extensionRuntime) ensureStorageLoaded() error {
|
|
r.storageMu.RLock()
|
|
if r.storageLoaded {
|
|
r.storageMu.RUnlock()
|
|
return nil
|
|
}
|
|
r.storageMu.RUnlock()
|
|
|
|
r.storageMu.Lock()
|
|
defer r.storageMu.Unlock()
|
|
if r.storageLoaded {
|
|
return nil
|
|
}
|
|
|
|
storagePath := r.getStoragePath()
|
|
fileMu := extensionFileMu(storagePath)
|
|
fileMu.Lock()
|
|
data, err := os.ReadFile(storagePath)
|
|
fileMu.Unlock()
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
r.storageCache = make(map[string]interface{})
|
|
r.storageLoaded = true
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
var storage map[string]interface{}
|
|
if err := json.Unmarshal(data, &storage); err != nil {
|
|
return err
|
|
}
|
|
if storage == nil {
|
|
storage = make(map[string]interface{})
|
|
}
|
|
|
|
r.storageCache = storage
|
|
r.storageLoaded = true
|
|
return nil
|
|
}
|
|
|
|
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
|
if err := r.ensureStorageLoaded(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.storageMu.RLock()
|
|
defer r.storageMu.RUnlock()
|
|
return cloneInterfaceMap(r.storageCache), nil
|
|
}
|
|
|
|
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
|
if r.storageClosed {
|
|
return
|
|
}
|
|
if r.storageTimer != nil {
|
|
return
|
|
}
|
|
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
|
}
|
|
|
|
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
|
data, err := json.Marshal(storage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
path := r.getStoragePath()
|
|
mu := extensionFileMu(path)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return writeExtensionFileLocked(path, data)
|
|
}
|
|
|
|
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
|
if err := r.flushStorageDirty(); err != nil {
|
|
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
|
}
|
|
}
|
|
|
|
func (r *extensionRuntime) flushStorageDirty() error {
|
|
r.storageMu.Lock()
|
|
if r.storageClosed {
|
|
r.storageTimer = nil
|
|
r.storageMu.Unlock()
|
|
return nil
|
|
}
|
|
if !r.storageDirty {
|
|
r.storageTimer = nil
|
|
r.storageMu.Unlock()
|
|
return nil
|
|
}
|
|
snapshot := cloneInterfaceMap(r.storageCache)
|
|
r.storageDirty = false
|
|
r.storageTimer = nil
|
|
r.storageMu.Unlock()
|
|
|
|
if err := r.persistStorageSnapshot(snapshot); err != nil {
|
|
r.storageMu.Lock()
|
|
r.storageDirty = true
|
|
r.queueStorageFlushLocked(storageFlushRetryDelay)
|
|
r.storageMu.Unlock()
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *extensionRuntime) flushStorageNow() error {
|
|
r.storageMu.Lock()
|
|
if r.storageTimer != nil {
|
|
r.storageTimer.Stop()
|
|
r.storageTimer = nil
|
|
}
|
|
if !r.storageLoaded || r.storageClosed {
|
|
r.storageMu.Unlock()
|
|
return nil
|
|
}
|
|
snapshot := cloneInterfaceMap(r.storageCache)
|
|
r.storageDirty = false
|
|
r.storageMu.Unlock()
|
|
|
|
return r.persistStorageSnapshot(snapshot)
|
|
}
|
|
|
|
func (r *extensionRuntime) closeStorageFlusher() {
|
|
r.storageMu.Lock()
|
|
r.storageClosed = true
|
|
r.storageDirty = false
|
|
if r.storageTimer != nil {
|
|
r.storageTimer.Stop()
|
|
r.storageTimer = nil
|
|
}
|
|
r.storageMu.Unlock()
|
|
}
|
|
|
|
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return goja.Undefined()
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
|
|
if err := r.ensureStorageLoaded(); err != nil {
|
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
|
return goja.Undefined()
|
|
}
|
|
|
|
r.storageMu.RLock()
|
|
value, exists := r.storageCache[key]
|
|
r.storageMu.RUnlock()
|
|
if !exists {
|
|
if len(call.Arguments) > 1 {
|
|
return call.Arguments[1]
|
|
}
|
|
return goja.Undefined()
|
|
}
|
|
|
|
return r.vm.ToValue(value)
|
|
}
|
|
|
|
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
value := call.Arguments[1].Export()
|
|
|
|
if err := r.ensureStorageLoaded(); err != nil {
|
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
r.storageMu.Lock()
|
|
if r.storageClosed {
|
|
r.storageMu.Unlock()
|
|
return r.vm.ToValue(false)
|
|
}
|
|
if existing, exists := r.storageCache[key]; exists {
|
|
if reflect.DeepEqual(existing, value) {
|
|
r.storageMu.Unlock()
|
|
return r.vm.ToValue(true)
|
|
}
|
|
}
|
|
r.storageCache[key] = value
|
|
r.storageDirty = true
|
|
r.queueStorageFlushLocked(r.storageFlushDelay)
|
|
r.storageMu.Unlock()
|
|
|
|
return r.vm.ToValue(true)
|
|
}
|
|
|
|
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
|
|
if err := r.ensureStorageLoaded(); err != nil {
|
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
r.storageMu.Lock()
|
|
if r.storageClosed {
|
|
r.storageMu.Unlock()
|
|
return r.vm.ToValue(false)
|
|
}
|
|
if _, exists := r.storageCache[key]; !exists {
|
|
r.storageMu.Unlock()
|
|
return r.vm.ToValue(true)
|
|
}
|
|
delete(r.storageCache, key)
|
|
r.storageDirty = true
|
|
r.queueStorageFlushLocked(r.storageFlushDelay)
|
|
r.storageMu.Unlock()
|
|
|
|
return r.vm.ToValue(true)
|
|
}
|
|
|
|
func (r *extensionRuntime) getCredentialsPath() string {
|
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
|
}
|
|
|
|
func (r *extensionRuntime) getSaltPath() string {
|
|
return filepath.Join(r.dataDir, ".cred_salt")
|
|
}
|
|
|
|
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|
saltPath := r.getSaltPath()
|
|
|
|
// Serialize concurrent runtimes: if two generated different salts, the
|
|
// loser's credentials would become undecryptable.
|
|
mu := extensionFileMu(saltPath)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
salt, err := os.ReadFile(saltPath)
|
|
if err == nil && len(salt) == 32 {
|
|
return salt, nil
|
|
}
|
|
|
|
salt = make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
|
}
|
|
|
|
if err := writeExtensionFileLocked(saltPath, salt); err != nil {
|
|
return nil, fmt.Errorf("failed to save salt: %w", err)
|
|
}
|
|
|
|
return salt, nil
|
|
}
|
|
|
|
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
|
salt, err := r.getOrCreateSalt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
combined := append([]byte(r.extensionID), salt...)
|
|
hash := sha256.Sum256(combined)
|
|
return hash[:], nil
|
|
}
|
|
|
|
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
|
r.credentialsMu.RLock()
|
|
if r.credentialsLoaded {
|
|
r.credentialsMu.RUnlock()
|
|
return nil
|
|
}
|
|
r.credentialsMu.RUnlock()
|
|
|
|
r.credentialsMu.Lock()
|
|
defer r.credentialsMu.Unlock()
|
|
if r.credentialsLoaded {
|
|
return nil
|
|
}
|
|
|
|
credPath := r.getCredentialsPath()
|
|
data, err := os.ReadFile(credPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
r.credentialsCache = make(map[string]interface{})
|
|
r.credentialsLoaded = true
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
key, err := r.getEncryptionKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get encryption key: %w", err)
|
|
}
|
|
decrypted, err := decryptAES(data, key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
|
}
|
|
|
|
var creds map[string]interface{}
|
|
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
|
return err
|
|
}
|
|
if creds == nil {
|
|
creds = make(map[string]interface{})
|
|
}
|
|
|
|
r.credentialsCache = creds
|
|
r.credentialsLoaded = true
|
|
return nil
|
|
}
|
|
|
|
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|
data, err := json.Marshal(creds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
key, err := r.getEncryptionKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get encryption key: %w", err)
|
|
}
|
|
encrypted, err := encryptAES(data, key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
|
}
|
|
|
|
credPath := r.getCredentialsPath()
|
|
credMu := extensionFileMu(credPath)
|
|
credMu.Lock()
|
|
err = writeExtensionFileLocked(credPath, encrypted)
|
|
credMu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
r.credentialsMu.Lock()
|
|
r.credentialsCache = cloneInterfaceMap(creds)
|
|
r.credentialsLoaded = true
|
|
r.credentialsMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "key and value are required",
|
|
})
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
value := call.Arguments[1].Export()
|
|
|
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
r.credentialsMu.RLock()
|
|
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
|
r.credentialsMu.RUnlock()
|
|
nextCreds[key] = value
|
|
|
|
if err := r.saveCredentials(nextCreds); err != nil {
|
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": true,
|
|
})
|
|
}
|
|
|
|
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return goja.Undefined()
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
|
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
|
return goja.Undefined()
|
|
}
|
|
|
|
r.credentialsMu.RLock()
|
|
value, exists := r.credentialsCache[key]
|
|
r.credentialsMu.RUnlock()
|
|
if !exists {
|
|
if len(call.Arguments) > 1 {
|
|
return call.Arguments[1]
|
|
}
|
|
return goja.Undefined()
|
|
}
|
|
|
|
return r.vm.ToValue(value)
|
|
}
|
|
|
|
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
|
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
r.credentialsMu.RLock()
|
|
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
|
r.credentialsMu.RUnlock()
|
|
delete(nextCreds, key)
|
|
|
|
if err := r.saveCredentials(nextCreds); err != nil {
|
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
return r.vm.ToValue(true)
|
|
}
|
|
|
|
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
key := call.Arguments[0].String()
|
|
|
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
r.credentialsMu.RLock()
|
|
_, exists := r.credentialsCache[key]
|
|
r.credentialsMu.RUnlock()
|
|
return r.vm.ToValue(exists)
|
|
}
|
|
|
|
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
return ciphertext, nil
|
|
}
|
|
|
|
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonceSize := gcm.NonceSize()
|
|
if len(ciphertext) < nonceSize {
|
|
return nil, fmt.Errorf("ciphertext too short")
|
|
}
|
|
|
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|