add token framing, crypto and errors

This commit is contained in:
juancwu 2026-04-29 01:28:05 +00:00
commit a6aad1a1d6
5 changed files with 448 additions and 0 deletions

164
framing_test.go Normal file
View 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)
}
}