vault.go raw

   1  package domain
   2  
   3  import (
   4  	"crypto/aes"
   5  	"crypto/cipher"
   6  	"crypto/rand"
   7  	"crypto/sha256"
   8  	"errors"
   9  	"fmt"
  10  
  11  	"golang.org/x/crypto/pbkdf2"
  12  )
  13  
  14  // The seed at rest.
  15  //
  16  // The phrase is the recovery artifact, so it has to be stored, and storing it in cleartext
  17  // would make every copy of the browser profile a wallet. It is sealed with AES-256-GCM under
  18  // a key derived from the person's passphrase. GCM is authenticated, so a damaged or edited
  19  // record is refused rather than decrypted into a different seed.
  20  //
  21  // What this does not do: it cannot stop an attacker who has the passphrase, and it does not
  22  // claim to. The point is that a stolen database is not a stolen wallet.
  23  const (
  24  	// vaultVersion is the envelope version.
  25  	vaultVersion uint8 = 1
  26  	// vaultSaltLen and vaultNonceLen are the crypto sizes, fixed so a decoder can be.
  27  	vaultSaltLen  = 16
  28  	vaultNonceLen = 12
  29  	// vaultKeyLen is the AES-256 key.
  30  	vaultKeyLen = 32
  31  	// vaultRounds is the PBKDF2 work factor a person's passphrase gets. Tests use seal
  32  	// with a smaller factor, because a work factor is the one thing worth not testing the
  33  	// cost of.
  34  	vaultRounds = 200_000
  35  	// vaultHeaderLen is version, salt and nonce.
  36  	vaultHeaderLen = 1 + vaultSaltLen + vaultNonceLen
  37  )
  38  
  39  // Errors the setup flow maps onto what a person is told. A wrong passphrase and a damaged
  40  // record are one error because the cipher cannot tell them apart.
  41  var (
  42  	// ErrNoPassphrase is a seal or an open with nothing to seal or open under.
  43  	ErrNoPassphrase = errors.New("a passphrase is required")
  44  	// ErrVault is a record that does not open: wrong passphrase, or damaged bytes.
  45  	ErrVault = errors.New("the passphrase does not open this wallet")
  46  )
  47  
  48  // Seal encrypts a phrase under a passphrase.
  49  func Seal(passphrase, phrase string) ([]byte, error) {
  50  	return seal(passphrase, phrase, vaultRounds)
  51  }
  52  
  53  // seal is Seal with a stated work factor.
  54  func seal(passphrase, phrase string, rounds int) ([]byte, error) {
  55  	if passphrase == "" {
  56  		return nil, ErrNoPassphrase
  57  	}
  58  
  59  	if phrase == "" {
  60  		return nil, errors.New("domain: refusing to seal an empty phrase")
  61  	}
  62  
  63  	salt := make([]byte, vaultSaltLen)
  64  
  65  	if _, err := rand.Read(salt); err != nil {
  66  		return nil, fmt.Errorf("domain: reading randomness: %w", err)
  67  	}
  68  
  69  	nonce := make([]byte, vaultNonceLen)
  70  
  71  	if _, err := rand.Read(nonce); err != nil {
  72  		return nil, fmt.Errorf("domain: reading randomness: %w", err)
  73  	}
  74  
  75  	aead, err := vaultAEAD(passphrase, salt, rounds)
  76  
  77  	if err != nil {
  78  		return nil, err
  79  	}
  80  
  81  	out := make([]byte, 0, vaultHeaderLen+len(phrase)+aead.Overhead())
  82  	out = append(out, vaultVersion)
  83  	out = append(out, salt...)
  84  	out = append(out, nonce...)
  85  	out = aead.Seal(out, nonce, []byte(phrase), nil)
  86  
  87  	return out, nil
  88  }
  89  
  90  // Open decrypts a phrase.
  91  func Open(passphrase string, sealed []byte) (string, error) {
  92  	return open(passphrase, sealed, vaultRounds)
  93  }
  94  
  95  // open is Open with a stated work factor, so a test can seal and open cheaply.
  96  func open(passphrase string, sealed []byte, rounds int) (string, error) {
  97  	if passphrase == "" {
  98  		return "", ErrNoPassphrase
  99  	}
 100  
 101  	if len(sealed) < vaultHeaderLen+16 {
 102  		return "", fmt.Errorf("%w: the record is too short", ErrVault)
 103  	}
 104  
 105  	if sealed[0] != vaultVersion {
 106  		return "", fmt.Errorf("%w: record version %d", ErrVault, sealed[0])
 107  	}
 108  
 109  	salt := sealed[1 : 1+vaultSaltLen]
 110  	nonce := sealed[1+vaultSaltLen : vaultHeaderLen]
 111  	body := sealed[vaultHeaderLen:]
 112  
 113  	aead, err := vaultAEAD(passphrase, salt, rounds)
 114  
 115  	if err != nil {
 116  		return "", err
 117  	}
 118  
 119  	phrase, err := aead.Open(nil, nonce, body, nil)
 120  
 121  	if err != nil {
 122  		return "", ErrVault
 123  	}
 124  
 125  	return string(phrase), nil
 126  }
 127  
 128  // vaultAEAD derives the key for a salt and builds the cipher.
 129  func vaultAEAD(passphrase string, salt []byte, rounds int) (cipher.AEAD, error) {
 130  	key := pbkdf2.Key([]byte(passphrase), salt, rounds, vaultKeyLen, sha256.New)
 131  
 132  	block, err := aes.NewCipher(key)
 133  
 134  	if err != nil {
 135  		return nil, fmt.Errorf("domain: %w", err)
 136  	}
 137  
 138  	aead, err := cipher.NewGCM(block)
 139  
 140  	if err != nil {
 141  		return nil, fmt.Errorf("domain: %w", err)
 142  	}
 143  
 144  	return aead, nil
 145  }
 146