From 6f5127dd56f2a2d2ee5c6c83b3dae51397617447 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 29 May 2024 23:20:24 -0700 Subject: [PATCH] Cold Storage with One-Way RSA Encryption --- .gitignore | 1 + cmd/nonshy/main.go | 54 ++++++++ pkg/config/variable.go | 19 ++- pkg/controller/admin/dashboard.go | 6 +- pkg/controller/photo/certification.go | 21 +++ pkg/encryption/coldstorage/coldstorage.go | 150 +++++++++++++++++++++ pkg/encryption/encryption.go | 54 +------- pkg/encryption/keygen/keygen.go | 156 +++++++++++++++++++++- web/templates/admin/dashboard.html | 7 + 9 files changed, 413 insertions(+), 55 deletions(-) create mode 100644 pkg/encryption/coldstorage/coldstorage.go diff --git a/.gitignore b/.gitignore index 7edf15e..ff8effa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /nonshy /web/static/photos +/coldstorage database.sqlite settings.json pgdump.sql diff --git a/cmd/nonshy/main.go b/cmd/nonshy/main.go index 9c96f15..fd6d50c 100644 --- a/cmd/nonshy/main.go +++ b/cmd/nonshy/main.go @@ -6,6 +6,7 @@ import ( nonshy "code.nonshy.com/nonshy/website/pkg" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models/backfill" @@ -169,6 +170,59 @@ func main() { }, }, }, + { + Name: "coldstorage", + Usage: "cold storage functions for sensitive files", + Subcommands: []*cli.Command{ + { + Name: "decrypt", + Usage: "decrypt a file from cold storage using the RSA private key", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Required: true, + Usage: "RSA private key file for cold storage", + }, + &cli.StringFlag{ + Name: "aes", + Aliases: []string{"a"}, + Required: true, + Usage: "AES key file used with the encrypted item in question (.aes file)", + }, + &cli.StringFlag{ + Name: "input", + Aliases: []string{"i"}, + Required: true, + Usage: "input file to decrypt (.enc file)", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Required: true, + Usage: "output file to write to (like a .jpg file)", + }, + }, + Action: func(c *cli.Context) error { + initdb(c) + + err := coldstorage.FileFromColdStorage( + c.String("key"), + c.String("aes"), + c.String("input"), + c.String("output"), + ) + if err != nil { + log.Error("Error decrypting from cold storage: %s") + return err + } + + log.Info("Wrote decrypted file to: %s", c.String("output")) + return nil + }, + }, + }, + }, { Name: "backfill", Usage: "One-off maintenance tasks and data backfills for database migrations", diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 81e6007..803ee4b 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/encryption/keygen" "code.nonshy.com/nonshy/website/pkg/log" "github.com/google/uuid" @@ -13,7 +14,7 @@ import ( // Version of the config format - when new fields are added, it will attempt // to write the settings.toml to disk so new defaults populate. -var currentVersion = 3 +var currentVersion = 4 // Current loaded settings.json var Current = DefaultVariable() @@ -97,6 +98,19 @@ func LoadSettings() { writeSettings = true } + // Initialize the cold storage ECDSA keys. + if len(Current.Encryption.ColdStorageRSAPublicKey) == 0 { + x509publicKey, err := coldstorage.Initialize() + if err != nil { + log.Error("Initializing cold storage: %s", err) + os.Exit(1) + } + + // Store the public key in the settings.json. + Current.Encryption.ColdStorageRSAPublicKey = x509publicKey + writeSettings = true + } + // Have we added new config fields? Save the settings.json. if Current.Version != currentVersion || writeSettings { log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") @@ -163,7 +177,8 @@ type Maintenance struct { // Encryption settings. type Encryption struct { - AESKey []byte + AESKey []byte + ColdStorageRSAPublicKey []byte } // Turnstile (Cloudflare CAPTCHA) settings. diff --git a/pkg/controller/admin/dashboard.go b/pkg/controller/admin/dashboard.go index 191a178..4e0351e 100644 --- a/pkg/controller/admin/dashboard.go +++ b/pkg/controller/admin/dashboard.go @@ -3,6 +3,7 @@ package admin import ( "net/http" + "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/templates" ) @@ -10,7 +11,10 @@ import ( func Dashboard() http.HandlerFunc { tmpl := templates.Must("admin/dashboard.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := tmpl.Execute(w, r, nil); err != nil { + var vars = map[string]interface{}{ + "ColdStorageWarning": coldstorage.Warning(), + } + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go index 3b54c77..3724590 100644 --- a/pkg/controller/photo/certification.go +++ b/pkg/controller/photo/certification.go @@ -7,9 +7,11 @@ import ( "net/http" "path/filepath" "strconv" + "time" "code.nonshy.com/nonshy/website/pkg/chat" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/mail" @@ -468,6 +470,25 @@ func AdminCertification() http.HandlerFunc { // With a secondary photo ID? if cert.SecondaryFilename != "" { + // Move the original photo into cold storage. + coldStorageFilename := fmt.Sprintf( + "photoID-%d-%s-%s.jpg", + user.ID, + user.Username, + time.Now().Format(time.RFC3339Nano), + ) + if err := coldstorage.FileToColdStorage( + photo.DiskPath(cert.SecondaryFilename), + coldStorageFilename, + config.Current.Encryption.ColdStorageRSAPublicKey, + ); err != nil { + session.FlashError(w, r, "Failed to move to cold storage: %s", err) + templates.Redirect(w, r.URL.Path) + return + } else { + session.Flash(w, r, "Note: the secondary photo ID was encrypted to cold storage @ %s", coldStorageFilename) + } + // Delete it immediately. if err := photo.Delete(cert.SecondaryFilename); err != nil { session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err) diff --git a/pkg/encryption/coldstorage/coldstorage.go b/pkg/encryption/coldstorage/coldstorage.go new file mode 100644 index 0000000..f683e23 --- /dev/null +++ b/pkg/encryption/coldstorage/coldstorage.go @@ -0,0 +1,150 @@ +package coldstorage + +import ( + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "os" + "path" + "path/filepath" + + "code.nonshy.com/nonshy/website/pkg/encryption/keygen" + "code.nonshy.com/nonshy/website/pkg/log" +) + +var ( + ColdStorageDirectory = "./coldstorage" + ColdStorageKeysDirectory = path.Join(ColdStorageDirectory, "keys") + ColdStoragePrivateKeyFile = path.Join(ColdStorageKeysDirectory, "private.pem") + ColdStoragePublicKeyFile = path.Join(ColdStorageKeysDirectory, "public.pem") +) + +// Initialize generates the RSA key pairs for the first time and creates +// the cold storage directories. It writes the keys to disk and returns the x509 encoded +// public key which goes in the settings.json (the keys on disk are for your bookkeeping). +func Initialize() ([]byte, error) { + log.Warn("NOTICE: rolling a random RSA key pair for cold storage") + rsaKey, err := keygen.NewRSAKeys() + if err != nil { + return nil, fmt.Errorf("generate RSA key: %s", err) + } + + // Encode to x509 + x509, err := keygen.SerializePublicKey(rsaKey.Public()) + if err != nil { + return nil, fmt.Errorf("encode RSA public key to x509: %s", err) + } + + // Write the public/private key files to disk. + if _, err := os.Stat(ColdStorageKeysDirectory); os.IsNotExist(err) { + log.Info("Notice: creating cold storage directory") + if err := os.MkdirAll(ColdStorageKeysDirectory, 0755); err != nil { + return nil, fmt.Errorf("create %s: %s", ColdStorageKeysDirectory, err) + } + } + if err := keygen.WriteRSAKeys( + rsaKey, + path.Join(ColdStorageKeysDirectory, "private.pem"), + path.Join(ColdStorageKeysDirectory, "public.pem"), + ); err != nil { + return nil, fmt.Errorf("export newly generated public/private key files: %s", err) + } + + return x509, nil +} + +// Warning returns an error message if the private key is still on disk at its +// original generated location: it should be moved offline for security. +func Warning() error { + if _, err := os.Stat(ColdStoragePrivateKeyFile); os.IsNotExist(err) { + return nil + } + + return errors.New("the private key file at ./coldstorage/keys should be moved off of the server and kept offline for safety") +} + +// FileToColdStorage will copy a file, encrypted, into cold storage at the given filename. +func FileToColdStorage(sourceFilePath, outputFileName string, publicKeyPEM []byte) error { + if len(publicKeyPEM) == 0 { + return errors.New("no RSA public key") + } + + // Load the public key from PEM encoding. + publicKey, err := keygen.DeserializePublicKey(publicKeyPEM) + if err != nil { + return fmt.Errorf("deserializing public key: %s", err) + } + + // Generate a unique AES key for encrypting this file in one direction. + aesKey, err := keygen.NewAESKey() + if err != nil { + return err + } + + // Encrypt the AES key and store it on disk next to the cold storage file. + ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, aesKey) + if err != nil { + return fmt.Errorf("encrypt error: %s", err) + } + err = os.WriteFile( + filepath.Join(ColdStorageDirectory, outputFileName+".aes"), + ciphertext, + 0600, + ) + if err != nil { + return err + } + + // Read the original plaintext file going into cold storage. + plaintext, err := os.ReadFile(sourceFilePath) + if err != nil { + return fmt.Errorf("source file: %s", err) + } + + // Encrypt the plaintext with the AES key. + ciphertext, err = keygen.EncryptWithAESKey(plaintext, aesKey) + if err != nil { + return err + } + + // Write it to disk. + return os.WriteFile(filepath.Join(ColdStorageDirectory, outputFileName+".enc"), ciphertext, 0600) +} + +// FileFromColdStorage decrypts a cold storage file and writes it to the output file. +// +// The command `nonshy coldstorage decrypt` uses this function. Requirements: +// +// - privateKeyFile is the RSA private key originally generated for cold storage +// - aesKeyFile is the unique .aes file for the cold storage item +// - ciphertextFile is the encrypted cold storage item +// - outputFile is where you want to save the result to +func FileFromColdStorage(privateKeyFile, aesKeyFile, ciphertextFile, outputFile string) error { + privateKey, err := keygen.PrivateKeyFromFile(privateKeyFile) + if err != nil { + return fmt.Errorf("private key file: %s", err) + } + + encryptedAESKey, err := os.ReadFile(aesKeyFile) + if err != nil { + return fmt.Errorf("reading aes key file: %s", err) + } + + aesKey, err := rsa.DecryptPKCS1v15(rand.Reader, privateKey, encryptedAESKey) + if err != nil { + return fmt.Errorf("decrypting the aes key file: %s", err) + } + + ciphertext, err := os.ReadFile(ciphertextFile) + if err != nil { + return fmt.Errorf("reading cold storage file: %s", err) + } + + plaintext, err := keygen.DecryptWithAESKey(ciphertext, aesKey) + if err != nil { + return fmt.Errorf("decrypting cold storage file: %s", err) + } + + return os.WriteFile(outputFile, plaintext, 0644) +} diff --git a/pkg/encryption/encryption.go b/pkg/encryption/encryption.go index 8d6e1ed..1f11950 100644 --- a/pkg/encryption/encryption.go +++ b/pkg/encryption/encryption.go @@ -7,15 +7,12 @@ package encryption import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" "crypto/sha256" "errors" "fmt" - "io" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption/keygen" ) // Encrypt a byte stream using the site's AES passphrase. @@ -24,32 +21,7 @@ func Encrypt(input []byte) ([]byte, error) { return nil, errors.New("AES key not configured") } - // Generate a new AES cipher. - c, err := aes.NewCipher(config.Current.Encryption.AESKey) - if err != nil { - return nil, err - } - - // gcm or Galois/Counter Mode - gcm, err := cipher.NewGCM(c) - if err != nil { - return nil, err - } - - // Create a new byte array the size of the GCM nonce - // which must be passed to Seal. - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, fmt.Errorf("populating the nonce: %s", err) - } - - // Encrypt the text using the Seal function. - // Seal encrypts and authenticates plaintext, authenticates the - // additional data and appends the result to dst, returning the - // updated slice. The nonce must be NonceSize() bytes long and - // unique for all time, for a given key. - result := gcm.Seal(nonce, nonce, input, nil) - return result, nil + return keygen.EncryptWithAESKey(input, config.Current.Encryption.AESKey) } // EncryptString encrypts a string value and returns the cipher text. @@ -63,27 +35,7 @@ func Decrypt(data []byte) ([]byte, error) { return nil, errors.New("AES key not configured") } - c, err := aes.NewCipher(config.Current.Encryption.AESKey) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(c) - if err != nil { - return nil, err - } - - nonceSize := gcm.NonceSize() - if len(data) < nonceSize { - return nil, errors.New("ciphertext data less than nonceSize") - } - - nonce, ciphertext := data[:nonceSize], data[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - return plaintext, nil + return keygen.DecryptWithAESKey(data, config.Current.Encryption.AESKey) } // DecryptString decrypts a string value from ciphertext. diff --git a/pkg/encryption/keygen/keygen.go b/pkg/encryption/keygen/keygen.go index 009aca0..ad001ce 100644 --- a/pkg/encryption/keygen/keygen.go +++ b/pkg/encryption/keygen/keygen.go @@ -1,7 +1,21 @@ // Package keygen provides the AES key initializer function. package keygen -import "crypto/rand" +import ( + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "os" + + "code.nonshy.com/nonshy/website/pkg/log" +) // NewAESKey returns a 32-byte (AES 256 bit) encryption key. func NewAESKey() ([]byte, error) { @@ -9,3 +23,143 @@ func NewAESKey() ([]byte, error) { _, err := rand.Read(result) return result, err } + +// EncryptWithAESKey a byte stream using a given AES key. +func EncryptWithAESKey(input []byte, key []byte) ([]byte, error) { + // Generate a new AES cipher. + c, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // gcm or Galois/Counter Mode + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, err + } + + // Create a new byte array the size of the GCM nonce + // which must be passed to Seal. + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("populating the nonce: %s", err) + } + + // Encrypt the text using the Seal function. + // Seal encrypts and authenticates plaintext, authenticates the + // additional data and appends the result to dst, returning the + // updated slice. The nonce must be NonceSize() bytes long and + // unique for all time, for a given key. + result := gcm.Seal(nonce, nonce, input, nil) + return result, nil +} + +func DecryptWithAESKey(data []byte, key []byte) ([]byte, error) { + c, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return nil, errors.New("ciphertext data less than nonceSize") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + return plaintext, nil +} + +// NewRSAKeys will generate an RSA 2048-bit key pair. +func NewRSAKeys() (*rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + return privateKey, err +} + +// SerializePublicKey converts an RSA public key into an x509 PEM encoded byte string. +func SerializePublicKey(publicKey crypto.PublicKey) ([]byte, error) { + // Encode the public key to PEM format. + x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return nil, err + } + pemEncodedPub := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509EncodedPub, + }) + return pemEncodedPub, nil +} + +// DeserializePublicKey loads the RSA public key from the PEM encoded byte array. +func DeserializePublicKey(pemEncodedPub []byte) (*rsa.PublicKey, error) { + // Decode the public key. + log.Error("decode public key: %s", pemEncodedPub) + blockPub, _ := pem.Decode(pemEncodedPub) + x509EncodedPub := blockPub.Bytes + genericPublicKey, err := x509.ParsePKIXPublicKey(x509EncodedPub) + if err != nil { + return nil, err + } + publicKey := genericPublicKey.(*rsa.PublicKey) + return publicKey, nil +} + +// WriteRSAKeys writes the public and private RSA keys to .pem files on disk. +func WriteRSAKeys(key *rsa.PrivateKey, privateFile, publicFile string) error { + // Encode the private key to PEM format. + x509Encoded := x509.MarshalPKCS1PrivateKey(key) + pemEncoded := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509Encoded, + }) + + // Encode the public key to PEM format. + pemEncodedPub, err := SerializePublicKey(key.Public()) + if err != nil { + return err + } + + // Write the files. + if err := os.WriteFile(privateFile, pemEncoded, 0600); err != nil { + return err + } + if err := os.WriteFile(publicFile, pemEncodedPub, 0644); err != nil { + return err + } + + return nil +} + +// PrivateKeyFromFile loads the private key from disk. +func PrivateKeyFromFile(privateFile string) (*rsa.PrivateKey, error) { + // Read the private key file. + pemEncoded, err := os.ReadFile(privateFile) + if err != nil { + return nil, err + } + + // Decode the private key. + block, _ := pem.Decode(pemEncoded) + x509Encoded := block.Bytes + privateKey, _ := x509.ParsePKCS1PrivateKey(x509Encoded) + return privateKey, nil +} + +// PublicKeyFromFile loads the public key from disk. +func PublicKeyFromFile(publicFile string) (*rsa.PublicKey, error) { + pemEncodedPub, err := os.ReadFile(publicFile) + if err != nil { + return nil, err + } + + // Decode the public key. + return DeserializePublicKey(pemEncodedPub) +} diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index 45b9015..bac15ce 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -9,6 +9,13 @@ + + {{if .ColdStorageWarning}} +
+ Cold Storage Warning: {{.ColdStorageWarning}}. +
+ {{end}} +