Add the Bitwarden crypto core pinned to published SDK test vectors
EncString parse/decrypt (type 0 and type 2: AES-256-CBC + HMAC-SHA256 over iv||ciphertext, PKCS#7), the machine-credential format (0.<uuid>.<secret>:<key>), and the HKDF shareable-key derivation that unwraps the organization key. Stdlib only. Test vectors come from the public Bitwarden SDK test suite, so the construction matches the official clients exactly.
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
package bitwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Machine-account credentials as printed by Bitwarden Secrets Manager are a
|
||||||
|
// single string:
|
||||||
|
//
|
||||||
|
// 0.<access-token-uuid>.<client-secret>:<base64 16-byte key>
|
||||||
|
//
|
||||||
|
// The uuid becomes the OAuth client_id, the client-secret the
|
||||||
|
// client_secret, and the 16-byte key seeds the HKDF derivation that
|
||||||
|
// unwraps the encrypted payload delivered with the access token (which in
|
||||||
|
// turn carries the organization key that decrypts secrets). Credential
|
||||||
|
// material is never logged and never appears in error strings; a bad
|
||||||
|
// credential yields only "malformed" without echoing the input.
|
||||||
|
|
||||||
|
// parseAccessToken splits a machine credential into its parts and derives
|
||||||
|
// the payload-unwrapping key (HKDF-SHA256, salt "bitwarden-accesstoken",
|
||||||
|
// info "sm-access-token", 64 bytes = AES||HMAC).
|
||||||
|
func parseAccessToken(s string) (clientID, clientSecret string, key *SymmetricKey, err error) {
|
||||||
|
head, keyPart, ok := strings.Cut(s, ":")
|
||||||
|
if !ok {
|
||||||
|
return "", "", nil, fmt.Errorf("%w: credential is not a v0 access token", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
parts := strings.Split(head, ".")
|
||||||
|
if len(parts) != 3 || parts[0] != "0" {
|
||||||
|
return "", "", nil, fmt.Errorf("%w: credential is not a v0 access token", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
clientID, clientSecret = parts[1], parts[2]
|
||||||
|
if clientID == "" || clientSecret == "" {
|
||||||
|
return "", "", nil, fmt.Errorf("%w: credential is not a v0 access token", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
raw, err := b64Decode(keyPart)
|
||||||
|
if err != nil || len(raw) != 16 {
|
||||||
|
return "", "", nil, fmt.Errorf("%w: credential key part is malformed", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
key, err = deriveShareableKey(raw, "accesstoken", "sm-access-token")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, fmt.Errorf("%w: credential key part is malformed", ErrInvalidCredentials)
|
||||||
|
}
|
||||||
|
return clientID, clientSecret, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveShareableKey mirrors Bitwarden's shareable-key derivation:
|
||||||
|
// HKDF-SHA256 with salt "bitwarden-<name>" and info (optional), producing
|
||||||
|
// a 64-byte AES||HMAC key.
|
||||||
|
func deriveShareableKey(secret []byte, name, info string) (*SymmetricKey, error) {
|
||||||
|
okm, err := hkdf.Key(sha256.New, secret, []byte("bitwarden-"+name), info, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewSymmetricKey(okm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtClaims is the set of claims this client reads from access-token JWTs
|
||||||
|
// (exp, subject, organization). Signatures are not verified locally, the
|
||||||
|
// same trust posture as the official clients: the server endpoint is
|
||||||
|
// reached over TLS and the claims are used only for expiry bookkeeping.
|
||||||
|
type jwtClaims struct {
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Organization string `json:"organization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWTClaims base64-decodes the payload segment of a JWT. It returns
|
||||||
|
// ok=false for non-JWT tokens (never an error with embedded content).
|
||||||
|
func parseJWTClaims(token string) (jwtClaims, bool) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return jwtClaims{}, false
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||||
|
if err != nil {
|
||||||
|
return jwtClaims{}, false
|
||||||
|
}
|
||||||
|
var c jwtClaims
|
||||||
|
if err := json.Unmarshal(raw, &c); err != nil {
|
||||||
|
return jwtClaims{}, false
|
||||||
|
}
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
package bitwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncString is the Bitwarden cipher-string wire format:
|
||||||
|
//
|
||||||
|
// <type>.<b64 iv>.<b64 ciphertext>[.<b64 mac>]
|
||||||
|
//
|
||||||
|
// Type 2 (AesCbc256_HmacSha256_B64) authenticates with HMAC-SHA256 over
|
||||||
|
// iv||ciphertext under the MAC half of a 64-byte key; type 0
|
||||||
|
// (AesCbc256_B64) is unauthenticated AES-256-CBC under a 32-byte key.
|
||||||
|
// Only the read paths Secrets Manager needs are implemented; anything
|
||||||
|
// else fails closed with a typed error. Cipher text is never included in
|
||||||
|
// error messages.
|
||||||
|
type EncString struct {
|
||||||
|
Type byte
|
||||||
|
IV []byte
|
||||||
|
CT []byte
|
||||||
|
MAC []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEncString decodes the wire format. ok=false means the value is not
|
||||||
|
// EncString-shaped at all (callers treat such values as plaintext).
|
||||||
|
func ParseEncString(s string) (EncString, bool) {
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) < 3 || len(parts[0]) != 1 {
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
t := parts[0][0]
|
||||||
|
switch t {
|
||||||
|
case '0', '2':
|
||||||
|
default:
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
iv, err := b64Decode(parts[1])
|
||||||
|
if err != nil || len(iv) != 16 {
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
ct, err := b64Decode(parts[2])
|
||||||
|
if err != nil || len(ct) == 0 || len(ct)%aes.BlockSize != 0 {
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
e := EncString{Type: t, IV: iv, CT: ct}
|
||||||
|
if t == '2' {
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
mac, err := b64Decode(parts[3])
|
||||||
|
if err != nil || len(mac) != sha256.Size {
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
e.MAC = mac
|
||||||
|
} else if len(parts) != 3 {
|
||||||
|
return EncString{}, false
|
||||||
|
}
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// String re-encodes as the wire format (used by the fake server in tests).
|
||||||
|
func (e EncString) String() string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%c.%s.%s", e.Type, base64.StdEncoding.EncodeToString(e.IV), base64.StdEncoding.EncodeToString(e.CT))
|
||||||
|
if len(e.MAC) > 0 {
|
||||||
|
b.WriteString("." + base64.StdEncoding.EncodeToString(e.MAC))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt unwraps e under key. It verifies the MAC for type-2 strings
|
||||||
|
// BEFORE decrypting and fails closed on any integrity error.
|
||||||
|
func (e EncString) Decrypt(key *SymmetricKey) ([]byte, error) {
|
||||||
|
if key == nil {
|
||||||
|
return nil, fmt.Errorf("%w: no key available", ErrDecrypt)
|
||||||
|
}
|
||||||
|
switch e.Type {
|
||||||
|
case '2':
|
||||||
|
if key.MACKey == nil {
|
||||||
|
return nil, fmt.Errorf("%w: mac key required", ErrDecrypt)
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, key.MACKey)
|
||||||
|
mac.Write(e.IV)
|
||||||
|
mac.Write(e.CT)
|
||||||
|
if !hmac.Equal(mac.Sum(nil), e.MAC) {
|
||||||
|
return nil, fmt.Errorf("%w: integrity check failed", ErrDecrypt)
|
||||||
|
}
|
||||||
|
case '0':
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: unsupported type", ErrDecrypt)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key.EncKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey)
|
||||||
|
}
|
||||||
|
pt := make([]byte, len(e.CT))
|
||||||
|
cipher.NewCBCDecrypter(block, e.IV).CryptBlocks(pt, e.CT)
|
||||||
|
out, err := pkcs7Unpad(pt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: bad padding", ErrDecrypt)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt seals pt under key with a fresh random IV (used by the fake
|
||||||
|
// server in tests to produce real Bitwarden-shaped payloads).
|
||||||
|
func Encrypt(key *SymmetricKey, pt []byte) (EncString, error) {
|
||||||
|
if key == nil || len(key.EncKey) != 32 {
|
||||||
|
return EncString{}, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey)
|
||||||
|
}
|
||||||
|
iv := make([]byte, aes.BlockSize)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||||
|
return EncString{}, fmt.Errorf("%w: entropy source", ErrDecrypt)
|
||||||
|
}
|
||||||
|
padded := pkcs7Pad(pt)
|
||||||
|
ct := make([]byte, len(padded))
|
||||||
|
block, err := aes.NewCipher(key.EncKey)
|
||||||
|
if err != nil {
|
||||||
|
return EncString{}, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey)
|
||||||
|
}
|
||||||
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
|
||||||
|
e := EncString{Type: '0', IV: iv, CT: ct}
|
||||||
|
if key.MACKey != nil {
|
||||||
|
mac := hmac.New(sha256.New, key.MACKey)
|
||||||
|
mac.Write(iv)
|
||||||
|
mac.Write(ct)
|
||||||
|
e.Type = '2'
|
||||||
|
e.MAC = mac.Sum(nil)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SymmetricKey is a Bitwarden symmetric key: a 32-byte AES key, or a
|
||||||
|
// 64-byte AES||HMAC key. Keys are held as bytes (not strings) so callers
|
||||||
|
// can zero them; they are never stringified or logged.
|
||||||
|
type SymmetricKey struct {
|
||||||
|
EncKey []byte
|
||||||
|
MACKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSymmetricKey builds a key from raw bytes: 64 bytes -> AES+HMAC,
|
||||||
|
// 32 bytes -> AES only (Bitwarden legacy org-key lengths).
|
||||||
|
func NewSymmetricKey(raw []byte) (*SymmetricKey, error) {
|
||||||
|
switch len(raw) {
|
||||||
|
case 64:
|
||||||
|
return &SymmetricKey{EncKey: raw[:32], MACKey: raw[32:]}, nil
|
||||||
|
case 32:
|
||||||
|
return &SymmetricKey{EncKey: raw}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero wipes the key material in place.
|
||||||
|
func (k *SymmetricKey) Zero() {
|
||||||
|
if k == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range k.EncKey {
|
||||||
|
k.EncKey[i] = 0
|
||||||
|
}
|
||||||
|
for i := range k.MACKey {
|
||||||
|
k.MACKey[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// b64Decode accepts standard base64 with or without padding (the Bitwarden
|
||||||
|
// servers emit padded values; their own parsers accept both).
|
||||||
|
func b64Decode(s string) ([]byte, error) {
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
return base64.RawStdEncoding.DecodeString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const errBadKey = "invalid key length"
|
||||||
|
|
||||||
|
func pkcs7Pad(pt []byte) []byte {
|
||||||
|
n := aes.BlockSize - len(pt)%aes.BlockSize
|
||||||
|
out := make([]byte, len(pt)+n)
|
||||||
|
copy(out, pt)
|
||||||
|
for i := len(pt); i < len(out); i++ {
|
||||||
|
out[i] = byte(n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pkcs7Unpad(pt []byte) ([]byte, error) {
|
||||||
|
if len(pt) == 0 || len(pt)%aes.BlockSize != 0 {
|
||||||
|
return nil, errors.New("not block aligned")
|
||||||
|
}
|
||||||
|
n := int(pt[len(pt)-1])
|
||||||
|
if n == 0 || n > aes.BlockSize || n > len(pt) {
|
||||||
|
return nil, errors.New("invalid padding")
|
||||||
|
}
|
||||||
|
for _, c := range pt[len(pt)-n:] {
|
||||||
|
if int(c) != n {
|
||||||
|
return nil, errors.New("invalid padding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pt[:len(pt)-n], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package bitwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vectors below are the published test vectors from the Bitwarden SDK
|
||||||
|
// (bitwarden/sdk-internal, crates bitwarden-crypto): they pin this
|
||||||
|
// stdlib-only implementation to the exact wire construction the official
|
||||||
|
// clients use.
|
||||||
|
|
||||||
|
func TestDeriveShareableKeyVectors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
secret string // raw bytes
|
||||||
|
salt string // name argument (salt becomes "bitwarden-"+name)
|
||||||
|
info string
|
||||||
|
want string // base64 of the 64-byte key
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no info",
|
||||||
|
secret: "&/$%F1a895g67HlX",
|
||||||
|
salt: "test_key",
|
||||||
|
info: "",
|
||||||
|
want: "4PV6+PcmF2w7YHRatvyMcVQtI7zvCyssv/wFWmzjiH6Iv9altjmDkuBD1aagLVaLezbthbSe+ktR+U6qswxNnQ==",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with info",
|
||||||
|
secret: "67t9b5g67$%Dh89n",
|
||||||
|
salt: "test_key",
|
||||||
|
info: "test",
|
||||||
|
want: "F9jVQmrACGx9VUPjuzfMYDjr726JtL300Y3Yg+VYUnVQtQ1s8oImJ5xtp1KALC9h2nav04++1LDW4iFD+infng==",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
key, err := deriveShareableKey([]byte(tc.secret), tc.salt, tc.info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derive: %v", err)
|
||||||
|
}
|
||||||
|
got := base64.StdEncoding.EncodeToString(append(key.EncKey, key.MACKey...))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("derived key mismatch:\n got %s\nwant %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAccessTokenVector(t *testing.T) {
|
||||||
|
// Published vector: the credential format and the exact derived
|
||||||
|
// payload-unwrapping key.
|
||||||
|
const cred = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ=="
|
||||||
|
id, secret, key, err := parseAccessToken(cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if id != "ec2c1d46-6a4b-4751-a310-af9601317f2d" {
|
||||||
|
t.Fatalf("client id: %s", id)
|
||||||
|
}
|
||||||
|
if secret != "C2IgxjjLF7qSshsbwe8JGcbM075YXw" {
|
||||||
|
t.Fatalf("client secret: %s", secret)
|
||||||
|
}
|
||||||
|
got := base64.StdEncoding.EncodeToString(append(key.EncKey, key.MACKey...))
|
||||||
|
const want = "H9/oIRLtL9nGCQOVDjSMoEbJsjWXSOCb3qeyDt6ckzS3FhyboEDWyTP/CQfbIszNmAVg2ExFganG1FVFGXO/Jg=="
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("derived key mismatch:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAccessTokenMalformed(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
"",
|
||||||
|
"nonsense",
|
||||||
|
"1.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==", // wrong version
|
||||||
|
"ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==", // no version part
|
||||||
|
"0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw", // no key part
|
||||||
|
"0..C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==", // empty id
|
||||||
|
"0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:c2hvcnQ=", // key not 16 bytes
|
||||||
|
"0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:!!!not-base64!!!", // key not base64
|
||||||
|
}
|
||||||
|
for _, s := range bad {
|
||||||
|
_, _, _, err := parseAccessToken(s)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for %q", s)
|
||||||
|
}
|
||||||
|
if s != "" && strings.Contains(err.Error(), s) {
|
||||||
|
t.Fatalf("error echoes credential: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyAesCbcHmacVector(t *testing.T) {
|
||||||
|
// Published SDK vector for the type-2 EncString construction
|
||||||
|
// (AES-256-CBC + HMAC-SHA256 over iv||ciphertext).
|
||||||
|
key := make([]byte, 64)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
sk, err := NewSymmetricKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("key: %v", err)
|
||||||
|
}
|
||||||
|
iv := []byte{216, 218, 36, 0, 196, 186, 150, 85, 49, 147, 110, 168, 185, 227, 42, 172}
|
||||||
|
ct := []byte{
|
||||||
|
234, 77, 16, 15, 189, 82, 36, 188, 182, 88, 64, 67, 145, 94, 30, 178, 36, 235, 130, 67,
|
||||||
|
255, 207, 183, 168, 73, 231, 82, 122, 193, 139, 25, 129,
|
||||||
|
}
|
||||||
|
mac := []byte{
|
||||||
|
60, 78, 44, 111, 72, 233, 3, 6, 86, 250, 217, 242, 62, 229, 184, 221, 231, 150, 189, 44,
|
||||||
|
99, 189, 220, 55, 196, 194, 101, 60, 102, 195, 149, 130,
|
||||||
|
}
|
||||||
|
const plaintext = "Bitwarden SDK test vector"
|
||||||
|
|
||||||
|
e := EncString{Type: '2', IV: iv, CT: ct, MAC: mac}
|
||||||
|
pt, err := e.Decrypt(sk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if string(pt) != plaintext {
|
||||||
|
t.Fatalf("plaintext mismatch: %q", pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
raw := make([]byte, 64)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = byte(i * 7)
|
||||||
|
}
|
||||||
|
sk, err := NewSymmetricKey(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("key: %v", err)
|
||||||
|
}
|
||||||
|
for _, pt := range []string{"", "a", "exactly-16-chars", "the quick brown fox jumps over the lazy dog 1234567890"} {
|
||||||
|
e, err := Encrypt(sk, []byte(pt))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt %q: %v", pt, err)
|
||||||
|
}
|
||||||
|
got, err := e.Decrypt(sk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt %q: %v", pt, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, []byte(pt)) {
|
||||||
|
t.Fatalf("round trip mismatch: %q != %q", got, pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTamperDetection(t *testing.T) {
|
||||||
|
raw := make([]byte, 64)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = byte(i)
|
||||||
|
}
|
||||||
|
sk, _ := NewSymmetricKey(raw)
|
||||||
|
e, err := Encrypt(sk, []byte("sensitive value"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt: %v", err)
|
||||||
|
}
|
||||||
|
tampered := e
|
||||||
|
tampered.CT[0] ^= 0xFF
|
||||||
|
if _, err := tampered.Decrypt(sk); err == nil {
|
||||||
|
t.Fatal("tampered ciphertext decrypted: MAC not enforced")
|
||||||
|
}
|
||||||
|
badMAC := e
|
||||||
|
badMAC.MAC[0] ^= 0xFF
|
||||||
|
if _, err := badMAC.Decrypt(sk); err == nil {
|
||||||
|
t.Fatal("tampered MAC accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseEncStringShapes(t *testing.T) {
|
||||||
|
raw := make([]byte, 64)
|
||||||
|
sk, _ := NewSymmetricKey(raw)
|
||||||
|
e, _ := Encrypt(sk, []byte("x"))
|
||||||
|
if _, ok := ParseEncString(e.String()); !ok {
|
||||||
|
t.Fatal("valid encstring not recognized")
|
||||||
|
}
|
||||||
|
notEnc := []string{"", "plaintext", "2.short", "2.aGVsbG8.aGVsbG8", "9.aGVsbG8.x.y.z", "2!!!"}
|
||||||
|
for _, s := range notEnc {
|
||||||
|
if _, ok := ParseEncString(s); ok {
|
||||||
|
t.Fatalf("non-encstring accepted: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSymmetricKeyLengths(t *testing.T) {
|
||||||
|
if _, err := NewSymmetricKey(make([]byte, 32)); err != nil {
|
||||||
|
t.Fatalf("32-byte key rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewSymmetricKey(make([]byte, 64)); err != nil {
|
||||||
|
t.Fatalf("64-byte key rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NewSymmetricKey(make([]byte, 16)); err == nil {
|
||||||
|
t.Fatal("16-byte key accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTClaims(t *testing.T) {
|
||||||
|
// header.payload.sig with payload {"sub":"acc","organization":"org-id","exp":1893456000}
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"acc","organization":"3fb1c0de-0000-4000-8000-000000000000","exp":1893456000}`))
|
||||||
|
tok := "eyJhbGciOiJIUzI1NiJ9." + payload + ".c2ln"
|
||||||
|
c, ok := parseJWTClaims(tok)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("claims not parsed")
|
||||||
|
}
|
||||||
|
if c.Organization != "3fb1c0de-0000-4000-8000-000000000000" || c.Exp != 1893456000 {
|
||||||
|
t.Fatalf("claims wrong: %+v", c)
|
||||||
|
}
|
||||||
|
if _, ok := parseJWTClaims("not-a-jwt"); ok {
|
||||||
|
t.Fatal("non-jwt accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user