Cold Storage with One-Way RSA Encryption #43

Merged
noah merged 2 commits from coldstorage into main 2024-05-31 00:01:32 +00:00
7 changed files with 218 additions and 72 deletions
Showing only changes of commit cc82fec108 - Show all commits

View File

@ -204,8 +204,6 @@ func main() {
}, },
}, },
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
initdb(c)
err := coldstorage.FileFromColdStorage( err := coldstorage.FileFromColdStorage(
c.String("key"), c.String("key"),
c.String("aes"), c.String("aes"),
@ -213,7 +211,7 @@ func main() {
c.String("output"), c.String("output"),
) )
if err != nil { if err != nil {
log.Error("Error decrypting from cold storage: %s") log.Error("Error decrypting from cold storage: %s", err)
return err return err
} }

70
docs/Cold Storage.md Normal file
View File

@ -0,0 +1,70 @@
# Cold Storage
One of the security features of the website is **cold storage** which implements a "one way" encryption process for archiving sensitive files on the site.
The first use case is to archive secondary photo IDs: if a user was requested to provide a scan of their government issued photo ID for approval, the site can archive the original copy to cold storage when approved in case of any future inquiry.
The cold storage feature works by encrypting the file using an RSA public key, and relies on the matching private key to be **removed** from the web server and kept offline; so in case of a hack or data breach, the key that can decrypt the cold storage files will **NOT** be kept on the same web server.
This document explains how this feature works and how to configure it.
## Initialization
When the server starts up and there is not a cold storage RSA key configured, the feature will be initialized by generating new RSA encryption keys:
* The directory `./coldstorage/keys` is created and the RSA keys will be written in files named **private.pem** and **public.pem**.
* The RSA public key is _also_ written into the **settings.json** file for the server, at the Encryption / ColdStorageRSAPublicKey property.
You should **move the keys OFF of your web server machine** and keep them safe for your bookkeeping. Notably, the `private.pem` key is the sensitive file that should be removed.
The app does not need either of these keys to remain on the server: the settings.json has a copy of the RSA public key which the app uses to create cold storage encrypted files.
### Admin Dashboard Warning
As a safety precaution: if the private.pem key remains on disk, a warning is shown at the top of the Admin Dashboard page of the website to remind you that the key should be removed and stored safely offline.
## Recovering from Cold Storage
Should you need to recover an encrypted file from cold storage, the `nonshy coldstorage decrypt` command built into the Go server binary has the function to decrypt the files.
Every item that is moved into cold storage generates two files: an encrypted AES key file (`.aes`) and the encrypted data file itself (with a `.enc` extension). For example, a "photo.jpg" might go into cold storage as two files: "photo.jpg.aes" and "photo.jpg.enc"
You will need the following three files to decrypt from cold storage:
1. The RSA private key file (private.pem)
2. The encrypted AES key file (.aes extension)
3. The encrypted cold storage data file (.enc extension)
The command to decrypt them is thus like:
```bash
# command example
nonshy coldstorage decrypt \
--key private.pem \
--aes photo.jpg.aes \
--input photo.jpg.enc \
--output photo.jpg
# short command line flags work too
nonshy coldstorage decrypt -k private.pem \
-a photo.jpg.aes -i photo.jpg.enc \
-o photo.jpg
```
The `--output` file is where the decrypted file will be written to.
## Encryption Algorithms
When a file is moved into cold storage:
1. A fresh new AES symmetric key is generated from scratch.
2. The AES key is encrypted using the **RSA public key** and written to the ".aes" file in the coldstorage/ folder.
3. The original file is encrypted using that AES symmetric key and written to the ".enc" file in the coldstorage/ folder.
At the end of the encrypt function: the web server no longer has the AES key and is _unable_ to decrypt it because the private key is not available (as it should be kept offline for security).
Decrypting a file out of cold storage is done like so:
1. The encrypted AES key is unlocked using the **RSA private key**.
2. The encrypted cold storage file (.enc) is decrypted with that AES key.
3. The cleartext data is written to the output file.

View File

@ -98,7 +98,7 @@ func LoadSettings() {
writeSettings = true writeSettings = true
} }
// Initialize the cold storage ECDSA keys. // Initialize the cold storage RSA keys.
if len(Current.Encryption.ColdStorageRSAPublicKey) == 0 { if len(Current.Encryption.ColdStorageRSAPublicKey) == 0 {
x509publicKey, err := coldstorage.Initialize() x509publicKey, err := coldstorage.Initialize()
if err != nil { if err != nil {

View File

@ -472,10 +472,10 @@ func AdminCertification() http.HandlerFunc {
if cert.SecondaryFilename != "" { if cert.SecondaryFilename != "" {
// Move the original photo into cold storage. // Move the original photo into cold storage.
coldStorageFilename := fmt.Sprintf( coldStorageFilename := fmt.Sprintf(
"photoID-%d-%s-%s.jpg", "photoID-%d-%s-%d.jpg",
user.ID, user.ID,
user.Username, user.Username,
time.Now().Format(time.RFC3339Nano), time.Now().Unix(),
) )
if err := coldstorage.FileToColdStorage( if err := coldstorage.FileToColdStorage(
photo.DiskPath(cert.SecondaryFilename), photo.DiskPath(cert.SecondaryFilename),

View File

@ -0,0 +1,72 @@
// Package keygen provides the AES key initializer function.
package keygen
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
func NewAESKey() ([]byte, error) {
var result = make([]byte, 32)
_, 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
}

View File

@ -0,0 +1,72 @@
package keygen_test
import (
"testing"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
)
func TestAES(t *testing.T) {
type testCase struct {
AESKey []byte // AES key, nil = generate a new one
Input []byte // input text to encrypt
Encrypted []byte // already encrypted text
Expect []byte // expected output on decrypt
}
var tests = []testCase{
{
Input: []byte("hello world"),
Expect: []byte("hello world"),
},
{
AESKey: []byte{170, 94, 243, 132, 85, 247, 149, 238, 245, 39, 140, 125, 226, 178, 134, 161, 17, 151, 139, 248, 16, 94, 165, 8, 102, 238, 214, 183, 86, 138, 219, 52},
Encrypted: []byte{146, 217, 250, 254, 70, 201, 27, 221, 92, 145, 77, 213, 211, 197, 63, 189, 220, 188, 78, 8, 217, 108, 136, 89, 156, 23, 179, 54, 209, 54, 244, 170, 182, 150, 242, 52, 112, 191, 216, 46},
Expect: []byte("goodbye mars"),
},
}
for i, test := range tests {
if len(test.AESKey) == 0 {
key, err := keygen.NewAESKey()
if err != nil {
t.Errorf("Test #%d: failed to generate new AES key: %s", i, err)
continue
}
test.AESKey = key
}
if len(test.Encrypted) == 0 {
enc, err := keygen.EncryptWithAESKey(test.Input, test.AESKey)
if err != nil {
t.Errorf("Test #%d: failed to encrypt input: %s", i, err)
continue
}
test.Encrypted = enc
}
// t.Errorf("Key: %+v\nEnc: %+v", test.AESKey, test.Encrypted)
dec, err := keygen.DecryptWithAESKey(test.Encrypted, test.AESKey)
if err != nil {
t.Errorf("Test #%d: failed to decrypt: %s", i, err)
continue
}
// compare the results
var ok = true
if len(dec) != len(test.Expect) {
ok = false
} else {
for j := range dec {
if test.Expect[j] != dec[j] {
ok = false
}
}
}
if !ok {
t.Errorf("Test #%d: got unexpected result from decrypt. Expected %s, got %s", i, test.Expect, dec)
continue
}
}
}

View File

@ -3,81 +3,15 @@ package keygen
import ( import (
"crypto" "crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"fmt"
"io"
"os" "os"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
) )
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
func NewAESKey() ([]byte, error) {
var result = make([]byte, 32)
_, 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. // NewRSAKeys will generate an RSA 2048-bit key pair.
func NewRSAKeys() (*rsa.PrivateKey, error) { func NewRSAKeys() (*rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048) privateKey, err := rsa.GenerateKey(rand.Reader, 2048)