Initial Backupper Unraid plugin
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var validID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
func New(dir string) *Store { return &Store{dir: dir} }
|
||||
|
||||
func (s *Store) Init() error {
|
||||
if err := os.MkdirAll(s.dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
keyPath := filepath.Join(s.dir, "secret.key")
|
||||
if _, err := os.Stat(keyPath); errors.Is(err, os.ErrNotExist) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(keyPath, key, 0600)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Put(id, kind, plaintext string) error {
|
||||
if !validID.MatchString(id) || kind == "" {
|
||||
return errors.New("invalid secret id or type")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key, err := s.key()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(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
|
||||
}
|
||||
sealed := gcm.Seal(nil, nonce, []byte(plaintext), []byte(id+":"+kind))
|
||||
record := Record{ID: id, Type: kind, Nonce: base64.StdEncoding.EncodeToString(nonce), Ciphertext: base64.StdEncoding.EncodeToString(sealed), UpdatedAt: time.Now().UTC()}
|
||||
b, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.dir, id+".json"), append(b, '\n'), 0600)
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (string, error) {
|
||||
if !validID.MatchString(id) {
|
||||
return "", errors.New("invalid secret id")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, err := os.ReadFile(filepath.Join(s.dir, id+".json"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var record Record
|
||||
if err := json.Unmarshal(b, &record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := s.key()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce, err := base64.StdEncoding.DecodeString(record.Nonce)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(record.Ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := gcm.Open(nil, nonce, sealed, []byte(record.ID+":"+record.Type))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
if !validID.MatchString(id) {
|
||||
return errors.New("invalid secret id")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
err := os.Remove(filepath.Join(s.dir, id+".json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) key() ([]byte, error) {
|
||||
key, err := os.ReadFile(filepath.Join(s.dir, "secret.key"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("invalid secret key length")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptedRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := New(dir)
|
||||
if err := s.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Put("repo", "restic-password", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := s.Get("repo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain != "correct horse battery staple" {
|
||||
t.Fatalf("unexpected plaintext %q", plain)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "repo.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), plain) {
|
||||
t.Fatal("plaintext stored on disk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCiphertextBoundToIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := New(dir)
|
||||
if err := s.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Put("one", "password", "secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "one.json"))
|
||||
var record Record
|
||||
if err := json.Unmarshal(b, &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.ID = "two"
|
||||
b, _ = json.Marshal(record)
|
||||
if err := os.WriteFile(filepath.Join(dir, "two.json"), b, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Get("two"); err == nil {
|
||||
t.Fatal("renamed secret record decrypted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user