package domain import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "errors" "fmt" "golang.org/x/crypto/pbkdf2" ) // The seed at rest. // // The phrase is the recovery artifact, so it has to be stored, and storing it in cleartext // would make every copy of the browser profile a wallet. It is sealed with AES-256-GCM under // a key derived from the person's passphrase. GCM is authenticated, so a damaged or edited // record is refused rather than decrypted into a different seed. // // What this does not do: it cannot stop an attacker who has the passphrase, and it does not // claim to. The point is that a stolen database is not a stolen wallet. const ( // vaultVersion is the envelope version. vaultVersion uint8 = 1 // vaultSaltLen and vaultNonceLen are the crypto sizes, fixed so a decoder can be. vaultSaltLen = 16 vaultNonceLen = 12 // vaultKeyLen is the AES-256 key. vaultKeyLen = 32 // vaultRounds is the PBKDF2 work factor a person's passphrase gets. Tests use seal // with a smaller factor, because a work factor is the one thing worth not testing the // cost of. vaultRounds = 200_000 // vaultHeaderLen is version, salt and nonce. vaultHeaderLen = 1 + vaultSaltLen + vaultNonceLen ) // Errors the setup flow maps onto what a person is told. A wrong passphrase and a damaged // record are one error because the cipher cannot tell them apart. var ( // ErrNoPassphrase is a seal or an open with nothing to seal or open under. ErrNoPassphrase = errors.New("a passphrase is required") // ErrVault is a record that does not open: wrong passphrase, or damaged bytes. ErrVault = errors.New("the passphrase does not open this wallet") ) // Seal encrypts a phrase under a passphrase. func Seal(passphrase, phrase string) ([]byte, error) { return seal(passphrase, phrase, vaultRounds) } // seal is Seal with a stated work factor. func seal(passphrase, phrase string, rounds int) ([]byte, error) { if passphrase == "" { return nil, ErrNoPassphrase } if phrase == "" { return nil, errors.New("domain: refusing to seal an empty phrase") } salt := make([]byte, vaultSaltLen) if _, err := rand.Read(salt); err != nil { return nil, fmt.Errorf("domain: reading randomness: %w", err) } nonce := make([]byte, vaultNonceLen) if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("domain: reading randomness: %w", err) } aead, err := vaultAEAD(passphrase, salt, rounds) if err != nil { return nil, err } out := make([]byte, 0, vaultHeaderLen+len(phrase)+aead.Overhead()) out = append(out, vaultVersion) out = append(out, salt...) out = append(out, nonce...) out = aead.Seal(out, nonce, []byte(phrase), nil) return out, nil } // Open decrypts a phrase. func Open(passphrase string, sealed []byte) (string, error) { return open(passphrase, sealed, vaultRounds) } // open is Open with a stated work factor, so a test can seal and open cheaply. func open(passphrase string, sealed []byte, rounds int) (string, error) { if passphrase == "" { return "", ErrNoPassphrase } if len(sealed) < vaultHeaderLen+16 { return "", fmt.Errorf("%w: the record is too short", ErrVault) } if sealed[0] != vaultVersion { return "", fmt.Errorf("%w: record version %d", ErrVault, sealed[0]) } salt := sealed[1 : 1+vaultSaltLen] nonce := sealed[1+vaultSaltLen : vaultHeaderLen] body := sealed[vaultHeaderLen:] aead, err := vaultAEAD(passphrase, salt, rounds) if err != nil { return "", err } phrase, err := aead.Open(nil, nonce, body, nil) if err != nil { return "", ErrVault } return string(phrase), nil } // vaultAEAD derives the key for a salt and builds the cipher. func vaultAEAD(passphrase string, salt []byte, rounds int) (cipher.AEAD, error) { key := pbkdf2.Key([]byte(passphrase), salt, rounds, vaultKeyLen, sha256.New) block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("domain: %w", err) } aead, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("domain: %w", err) } return aead, nil }