add token framing, crypto and errors
This commit is contained in:
parent
71483982ca
commit
a6aad1a1d6
5 changed files with 448 additions and 0 deletions
164
framing_test.go
Normal file
164
framing_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package ficha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeRoundtrip(t *testing.T) {
|
||||
keyID := "k1"
|
||||
nonce := bytes.Repeat([]byte{0xAB}, nonceSize)
|
||||
ciphertext := []byte("not-real-ciphertext-but-long-enough-for-tag")
|
||||
|
||||
token := encodeToken(keyID, nonce, ciphertext)
|
||||
|
||||
if !strings.HasPrefix(token, "v1.k1.") {
|
||||
t.Errorf("unexpected prefix: %s", token)
|
||||
}
|
||||
if strings.ContainsAny(token, "+/=") {
|
||||
t.Errorf("token should be url-safe with no padding: %s", token)
|
||||
}
|
||||
|
||||
gotKeyID, gotNonce, gotCT, err := decodeToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeToken: %v", err)
|
||||
}
|
||||
if gotKeyID != keyID {
|
||||
t.Errorf("keyID: got %q, want %q", gotKeyID, keyID)
|
||||
}
|
||||
if !bytes.Equal(gotNonce, nonce) {
|
||||
t.Errorf("nonce: got %x, want %x", gotNonce, nonce)
|
||||
}
|
||||
if !bytes.Equal(gotCT, ciphertext) {
|
||||
t.Errorf("ciphertext: got %q, want %q", gotCT, ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTokenMalformed(t *testing.T) {
|
||||
// Build a baseline valid token to mutate.
|
||||
validNonce := bytes.Repeat([]byte{0x01}, nonceSize)
|
||||
validCT := bytes.Repeat([]byte{0x02}, 32) // > 16 byte tag minimum
|
||||
valid := encodeToken("k1", validNonce, validCT)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"only version", "v1"},
|
||||
{"missing body", "v1.k1"},
|
||||
{"too few parts", "v1.k1"},
|
||||
{"unknown version", "v9.k1.YWFhYQ"},
|
||||
{"empty key id", "v1..YWFhYQ"},
|
||||
{"invalid base64", "v1.k1.!!!not_base64!!!"},
|
||||
{"body too short", "v1.k1.YWFhYQ"}, // 4 bytes < nonceSize + 16
|
||||
{"body too short after truncation", valid[:len("v1.k1.")+8]},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, _, err := decodeToken(tc.input)
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("expected ErrInvalidToken, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAadIsDeterministic(t *testing.T) {
|
||||
a := aadFor("k1")
|
||||
b := aadFor("k1")
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Errorf("aadFor not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
|
||||
c := aadFor("k2")
|
||||
if bytes.Equal(a, c) {
|
||||
t.Error("different key IDs produced the same AAD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFramingEndToEndWithCrypto(t *testing.T) {
|
||||
// This is the first test that exercises crypto + framing together.
|
||||
// It catches mismatches between encrypt's AAD and decrypt's AAD.
|
||||
key := testKey(t)
|
||||
keyID := "k1"
|
||||
plaintext := []byte("framing meets crypto")
|
||||
|
||||
aad := aadFor(keyID)
|
||||
nonce, ct, err := encrypt(key, plaintext, aad)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
|
||||
token := encodeToken(keyID, nonce, ct)
|
||||
|
||||
gotKeyID, gotNonce, gotCT, err := decodeToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeToken: %v", err)
|
||||
}
|
||||
|
||||
got, err := decrypt(key, gotNonce, gotCT, aadFor(gotKeyID))
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Errorf("got %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFramingTamperKeyIDFailsAuth(t *testing.T) {
|
||||
// If an attacker swaps the key ID in the wire format, AAD changes,
|
||||
// and decrypt should fail. This is the whole point of putting
|
||||
// keyID into the AAD.
|
||||
key := testKey(t)
|
||||
plaintext := []byte("bind keyID to ciphertext")
|
||||
|
||||
nonce, ct, err := encrypt(key, plaintext, aadFor("k1"))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Build a token claiming to be from k1, then rewrite to k2.
|
||||
original := encodeToken("k1", nonce, ct)
|
||||
tampered := strings.Replace(original, "v1.k1.", "v1.k2.", 1)
|
||||
|
||||
_, gotNonce, gotCT, err := decodeToken(tampered)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeToken: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt under the SAME key but with the tampered AAD.
|
||||
if _, err := decrypt(key, gotNonce, gotCT, aadFor("k2")); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("expected ErrInvalidToken on key ID swap, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFramingTruncationFailsAuth(t *testing.T) {
|
||||
// A token that's structurally valid but had bytes lopped off
|
||||
// should pass framing and fail authentication.
|
||||
key := testKey(t)
|
||||
plaintext := []byte("hello, ficha")
|
||||
|
||||
nonce, ct, err := encrypt(key, plaintext, aadFor("k1"))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
|
||||
token := encodeToken("k1", nonce, ct)
|
||||
truncated := token[:len(token)-5]
|
||||
|
||||
// Framing may or may not accept it (depends on how much got chopped),
|
||||
// but if it does, decryption MUST fail.
|
||||
gotKeyID, gotNonce, gotCT, err := decodeToken(truncated)
|
||||
if err != nil {
|
||||
// Truncated past the minimum — framing rejected it. Also fine.
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := decrypt(key, gotNonce, gotCT, aadFor(gotKeyID)); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("expected ErrInvalidToken on truncated ciphertext, got %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue