pure-Go smcli: Bitwarden/Vaultwarden client replacing upstream Rust bw
ci / vet (pull_request) Failing after 12s
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
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user