ci / vet (pull_request) Failing after 12s
Full client-side crypto (PBKDF2/Argon2id master key, HKDF stretch, AES-256-CBC+HMAC encstrings), password grant with TOTP 2FA, sync, list/get/env/set/rm. Containerized (alpine, non-root), CI = gofmt/vet/ build/secret-scan, compose service ukrrs-secretsmgr-cli. Rust-era scripts archived. Live-validated against pwvault.turnsys.com. Ticket: https://projects.knownelement.com/issues/832
205 lines
5.5 KiB
Go
205 lines
5.5 KiB
Go
package main
|
|
|
|
// Bitwarden-compatible crypto for the KNELSecretsManager CLI (Vaultwarden API).
|
|
// - master key: PBKDF2-SHA256(password, email, iterations, 32B) or Argon2id
|
|
// - auth: masterPasswordHash = base64(PBKDF2-SHA256(masterKey, password, 1, 32B))
|
|
// - stretched master key: HKDF-SHA256 expand, info "enc"/"mac" (32B each)
|
|
// - encString "2.iv|ct|mac": AES-256-CBC(encKey32) + HMAC-SHA256(macKey32, iv||ct)
|
|
// ("AesCbc128_HmacSha256_B64" is Bitwarden's legacy misnomer; keys are 32+32 bytes)
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
"golang.org/x/crypto/pbkdf2"
|
|
)
|
|
|
|
type EncString struct {
|
|
Type byte
|
|
IV []byte
|
|
CT []byte
|
|
MAC []byte
|
|
}
|
|
|
|
func ParseEncString(s string) (*EncString, error) {
|
|
if s == "" {
|
|
return nil, errors.New("empty encstring")
|
|
}
|
|
parts := strings.SplitN(s, ".", 2)
|
|
if len(parts) != 2 {
|
|
return nil, errors.New("encstring missing type header")
|
|
}
|
|
t, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bad encstring type: %w", err)
|
|
}
|
|
es := &EncString{Type: byte(t)}
|
|
switch t {
|
|
case 0: // AesCbc256_B64 (legacy, no mac)
|
|
b, err := base64.StdEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
es.IV, es.CT = b[:16], b[16:]
|
|
case 1, 2: // both handled identically on the wire (enc32+mac32 keys)
|
|
seg := strings.Split(parts[1], "|")
|
|
if len(seg) != 3 {
|
|
return nil, errors.New("encstring needs iv|ct|mac")
|
|
}
|
|
if es.IV, err = base64.StdEncoding.DecodeString(seg[0]); err != nil {
|
|
return nil, err
|
|
}
|
|
if es.CT, err = base64.StdEncoding.DecodeString(seg[1]); err != nil {
|
|
return nil, err
|
|
}
|
|
if es.MAC, err = base64.StdEncoding.DecodeString(seg[2]); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported encstring type %d", t)
|
|
}
|
|
return es, nil
|
|
}
|
|
|
|
func (e *EncString) String() string {
|
|
if e.Type == 0 {
|
|
return "0." + base64.StdEncoding.EncodeToString(append(append([]byte{}, e.IV...), e.CT...))
|
|
}
|
|
return fmt.Sprintf("%d.%s|%s|%s", e.Type,
|
|
base64.StdEncoding.EncodeToString(e.IV),
|
|
base64.StdEncoding.EncodeToString(e.CT),
|
|
base64.StdEncoding.EncodeToString(e.MAC))
|
|
}
|
|
|
|
func deriveMasterKey(password, email string, kdfType int, iterations, memory, parallelism uint32) []byte {
|
|
salt := []byte(strings.ToLower(strings.TrimSpace(email)))
|
|
switch kdfType {
|
|
case 1:
|
|
return argon2.IDKey([]byte(password), salt, iterations, memory, uint8(parallelism), 32)
|
|
default:
|
|
return pbkdf2.Key([]byte(password), salt, int(iterations), 32, sha256.New)
|
|
}
|
|
}
|
|
|
|
func masterPasswordHash(masterKey []byte, password string) string {
|
|
return base64.StdEncoding.EncodeToString(pbkdf2.Key(masterKey, []byte(password), 1, 32, sha256.New))
|
|
}
|
|
|
|
// stretchKey expands the 32B master key to a 64B symmetric key (enc 32 | mac 32).
|
|
func stretchKey(masterKey []byte) []byte {
|
|
out := make([]byte, 64)
|
|
copy(out[:32], hkdfExpand(masterKey, []byte("enc"), 32))
|
|
copy(out[32:], hkdfExpand(masterKey, []byte("mac"), 32))
|
|
return out
|
|
}
|
|
|
|
func hkdfExpand(key, info []byte, length int) []byte {
|
|
out := make([]byte, 0, length)
|
|
t := []byte{}
|
|
var i byte
|
|
for len(out) < length {
|
|
i++
|
|
h := hmac.New(sha256.New, key)
|
|
h.Write(t)
|
|
h.Write(info)
|
|
h.Write([]byte{i})
|
|
t = h.Sum(nil)
|
|
out = append(out, t...)
|
|
}
|
|
return out[:length]
|
|
}
|
|
|
|
// symDecrypt decrypts a type-1/2 encstring with a 64-byte key (enc32|mac32).
|
|
func symDecrypt(key64 []byte, es *EncString) ([]byte, error) {
|
|
if len(key64) != 64 {
|
|
return nil, errors.New("symmetric key must be 64 bytes")
|
|
}
|
|
if es.Type == 0 {
|
|
return aesCBCDecrypt(key64[:32], es.IV, es.CT)
|
|
}
|
|
if es.Type != 1 && es.Type != 2 {
|
|
return nil, fmt.Errorf("unsupported encstring type %d", es.Type)
|
|
}
|
|
mac := hmac.New(sha256.New, key64[32:])
|
|
mac.Write(es.IV)
|
|
mac.Write(es.CT)
|
|
if subtle.ConstantTimeCompare(mac.Sum(nil), es.MAC) != 1 {
|
|
return nil, errors.New("mac mismatch")
|
|
}
|
|
return aesCBCDecrypt(key64[:32], es.IV, es.CT)
|
|
}
|
|
|
|
// symEncrypt encrypts plaintext into a type-2 encstring with a 64-byte key.
|
|
func symEncrypt(key64 []byte, plaintext []byte) (string, error) {
|
|
if len(key64) != 64 {
|
|
return "", errors.New("symmetric key must be 64 bytes")
|
|
}
|
|
iv := make([]byte, 16)
|
|
if _, err := rand.Read(iv); err != nil {
|
|
return "", err
|
|
}
|
|
ct, err := aesCBCEncrypt(key64[:32], iv, plaintext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mac := hmac.New(sha256.New, key64[32:])
|
|
mac.Write(iv)
|
|
mac.Write(ct)
|
|
es := &EncString{Type: 2, IV: iv, CT: ct, MAC: mac.Sum(nil)}
|
|
return es.String(), nil
|
|
}
|
|
|
|
func aesCBCDecrypt(key, iv, ct []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ct) == 0 || len(ct)%aes.BlockSize != 0 {
|
|
return nil, errors.New("ciphertext not block aligned")
|
|
}
|
|
pt := make([]byte, len(ct))
|
|
cipher.NewCBCDecrypter(block, iv).CryptBlocks(pt, ct)
|
|
return unpad(pt)
|
|
}
|
|
|
|
func aesCBCEncrypt(key, iv, pt []byte) ([]byte, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ct := make([]byte, len(pad(pt)))
|
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, pad(pt))
|
|
return ct, nil
|
|
}
|
|
|
|
func unpad(b []byte) ([]byte, error) {
|
|
if len(b) == 0 {
|
|
return nil, errors.New("empty plaintext")
|
|
}
|
|
n := int(b[len(b)-1])
|
|
if n == 0 || n > aes.BlockSize || n > len(b) {
|
|
return nil, errors.New("bad padding")
|
|
}
|
|
return b[:len(b)-n], nil
|
|
}
|
|
|
|
func pad(b []byte) []byte {
|
|
n := aes.BlockSize - len(b)%aes.BlockSize
|
|
out := make([]byte, len(b)+n)
|
|
copy(out, b)
|
|
for i := len(b); i < len(out); i++ {
|
|
out[i] = byte(n)
|
|
}
|
|
return out
|
|
}
|