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