392 lines
14 KiB
Go
392 lines
14 KiB
Go
package signature
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"google.golang.org/protobuf/encoding/protowire"
|
|
)
|
|
|
|
func TestInspectGrokEncryptedContent_NativeSamples(t *testing.T) {
|
|
path, ok := grokEncryptedContentSamplesPath()
|
|
if !ok {
|
|
t.Skip("grok encrypted_content corpus missing; run docs/native-prompt-capture/scripts/harvest-grok-encrypted-content.sh")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read samples: %v", err)
|
|
}
|
|
var samples []string
|
|
if err := json.Unmarshal(raw, &samples); err != nil {
|
|
t.Fatalf("unmarshal samples: %v", err)
|
|
}
|
|
if len(samples) == 0 {
|
|
t.Fatal("expected native Grok encrypted_content samples")
|
|
}
|
|
for i, sample := range samples {
|
|
if _, err := InspectGrokEncryptedContent(sample); err != nil {
|
|
t.Fatalf("sample[%d] should be valid, got %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsAgyGeminiThoughtSignatures(t *testing.T) {
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("runtime.Caller failed")
|
|
}
|
|
path := filepath.Join(filepath.Dir(file), "testdata", "agy_gemini_thought_signature_entries.json")
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
t.Skip("agy gemini corpus missing; run harvest_agy_gemini_signatures.py")
|
|
} else if err != nil {
|
|
t.Fatalf("stat samples: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read samples: %v", err)
|
|
}
|
|
var entries []struct {
|
|
ThoughtSignature string `json:"thoughtSignature"`
|
|
}
|
|
if err := json.Unmarshal(raw, &entries); err != nil {
|
|
t.Fatalf("unmarshal samples: %v", err)
|
|
}
|
|
if len(entries) == 0 {
|
|
t.Fatal("expected agy Gemini thought signatures")
|
|
}
|
|
checkedUnpaddedGemini := false
|
|
for i, entry := range entries {
|
|
_, err := InspectGrokEncryptedContent(entry.ThoughtSignature)
|
|
if err == nil {
|
|
t.Fatalf("entry[%d] should not pass as Grok encrypted_content", i)
|
|
}
|
|
if !strings.Contains(entry.ThoughtSignature, "=") {
|
|
checkedUnpaddedGemini = true
|
|
if !strings.Contains(err.Error(), "Gemini") {
|
|
t.Fatalf("entry[%d] error = %q, want Gemini fast-reject detail", i, err.Error())
|
|
}
|
|
}
|
|
}
|
|
if !checkedUnpaddedGemini {
|
|
t.Fatal("expected at least one unpadded Gemini thought signature sample")
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsGeminiThoughtSignatureEnvelope(t *testing.T) {
|
|
sample := testGeminiThoughtSignatureEnvelope()
|
|
|
|
_, err := InspectGrokEncryptedContent(sample)
|
|
if err == nil {
|
|
t.Fatal("expected Gemini thoughtSignature envelope to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "Gemini") {
|
|
t.Fatalf("error = %q, want Gemini fast-reject detail", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestInspectGrokEncryptedContent_RetiredGemini25Field1Envelope covers the
|
|
// retired Gemini 2.5 envelope. It is no longer a known Gemini envelope, so the
|
|
// Gemini fast-reject no longer fires for it and it falls to the residual class
|
|
// like any other opaque payload. Recorded here so the change is deliberate rather
|
|
// than an accident of the Gemini validator being narrowed.
|
|
func TestInspectGrokEncryptedContent_RetiredGemini25Field1Envelope(t *testing.T) {
|
|
sample := testGemini25Field1ThoughtSignatureEnvelope()
|
|
if IsValidGeminiThoughtSignature(sample, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) {
|
|
t.Fatal("fixture should no longer be a known Gemini thoughtSignature")
|
|
}
|
|
if _, err := InspectGrokEncryptedContent(sample); err != nil {
|
|
t.Fatalf("retired envelope should reach the residual transport check, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsClaudeThinkingSignature(t *testing.T) {
|
|
sample := testUnpaddedClaudeThinkingSignature()
|
|
if !IsValidClaudeThinkingSignature(sample, ClaudeSignatureValidationOptions{Strict: true}) {
|
|
t.Fatal("fixture should be a strict Claude thinking signature")
|
|
}
|
|
|
|
_, err := InspectGrokEncryptedContent(sample)
|
|
if err == nil {
|
|
t.Fatal("expected Claude thinking signature to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "Claude") {
|
|
t.Fatalf("error = %q, want Claude fast-reject detail", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsAntigravityClaudeThinkingSignature(t *testing.T) {
|
|
sample := testUnpaddedAntigravityClaudeThinkingSignature()
|
|
if !strings.HasPrefix(sample, "R") || strings.Contains(sample, "=") {
|
|
t.Fatalf("fixture should be an unpadded R-form Claude signature, got prefix=%q has_padding=%t", sample[:1], strings.Contains(sample, "="))
|
|
}
|
|
if !IsValidClaudeThinkingSignature(sample, ClaudeSignatureValidationOptions{Strict: true}) {
|
|
t.Fatal("fixture should be a strict Antigravity Claude thinking signature")
|
|
}
|
|
|
|
_, err := InspectGrokEncryptedContent(sample)
|
|
if err == nil {
|
|
t.Fatal("expected Antigravity Claude thinking signature to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "Claude") {
|
|
t.Fatalf("error = %q, want Claude fast-reject detail", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestInspectGrokEncryptedContent_RejectsClaudeCAISSignature covers the CAIS
|
|
// envelope emitted by the newest Claude Code models. CAIS payloads are
|
|
// high-entropy standard base64 and drop their padding whenever the decoded
|
|
// length is a multiple of 3, so neither the padding gate nor the classic Claude
|
|
// strict check excludes them on their own.
|
|
func TestInspectGrokEncryptedContent_RejectsClaudeCAISSignature(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
sample string
|
|
}{
|
|
{name: "synthetic unpadded", sample: testUnpaddedClaudeCAISSignature()},
|
|
{name: "observed fable-5", sample: observedFable5Sample},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if strings.Contains(tc.sample, "=") {
|
|
t.Fatal("fixture must be unpadded so it reaches the Claude CAIS check")
|
|
}
|
|
if !IsValidClaudeCAISSignature(tc.sample) {
|
|
t.Fatal("fixture should be a valid Claude CAIS signature")
|
|
}
|
|
if IsValidClaudeThinkingSignature(tc.sample, ClaudeSignatureValidationOptions{Strict: true}) {
|
|
t.Fatal("CAIS fixture must not also pass classic Claude validation")
|
|
}
|
|
|
|
_, err := InspectGrokEncryptedContent(tc.sample)
|
|
if err == nil {
|
|
t.Fatal("expected Claude CAIS signature to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "CAIS") {
|
|
t.Fatalf("error = %q, want Claude CAIS fast-reject detail", err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInspectGrokEncryptedContent_RejectsProviderCachePrefix keeps provenance
|
|
// envelopes out of the residual class. A prefixed value belongs to whichever
|
|
// provider the prefix names, and must never be replayed to xAI verbatim.
|
|
func TestInspectGrokEncryptedContent_RejectsProviderCachePrefix(t *testing.T) {
|
|
for _, prefix := range []string{"claude#", "anthropic#", "gemini#", "openai#", "codex#"} {
|
|
sample := prefix + testUnpaddedClaudeCAISSignature()
|
|
if _, err := InspectGrokEncryptedContent(sample); err == nil {
|
|
t.Fatalf("%s prefixed payload should be rejected", prefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInspectGrokEncryptedContent_ThresholdMargins documents that neither
|
|
// threshold sits on observed data. The shortest observed native payload is 50
|
|
// decoded bytes and the lowest observed entropy ratio is 0.892, so both limits
|
|
// keep headroom for future models rather than fitting the current corpus exactly.
|
|
func TestInspectGrokEncryptedContent_ThresholdMargins(t *testing.T) {
|
|
const shortestObservedDecodedLen = 50
|
|
const lowestObservedEntropyRatio = 0.892
|
|
|
|
if MinGrokEncryptedContentDecodedLen >= shortestObservedDecodedLen {
|
|
t.Fatalf("MinGrokEncryptedContentDecodedLen = %d, want below the shortest observed payload (%d) so a shorter future payload is not silently dropped",
|
|
MinGrokEncryptedContentDecodedLen, shortestObservedDecodedLen)
|
|
}
|
|
if MinGrokEncryptedContentEntropyRatio >= lowestObservedEntropyRatio {
|
|
t.Fatalf("MinGrokEncryptedContentEntropyRatio = %.3f, want below the lowest observed ratio (%.3f)",
|
|
MinGrokEncryptedContentEntropyRatio, lowestObservedEntropyRatio)
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsForeignShapes(t *testing.T) {
|
|
cases := []string{
|
|
"",
|
|
"bad",
|
|
" opaque",
|
|
"gAAAAABinvalid-gpt-shape",
|
|
"abcd_efg",
|
|
base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xa5}, MinGrokEncryptedContentDecodedLen)),
|
|
}
|
|
for _, sample := range cases {
|
|
if _, err := InspectGrokEncryptedContent(sample); err == nil {
|
|
t.Fatalf("expected invalid Grok encrypted_content, got pass for %q", sample)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsLowEntropyPayload(t *testing.T) {
|
|
sample := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{0xa5}, MinGrokEncryptedContentDecodedLen))
|
|
|
|
_, err := InspectGrokEncryptedContent(sample)
|
|
if err == nil {
|
|
t.Fatal("expected low-entropy payload to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "entropy ratio") {
|
|
t.Fatalf("error = %q, want entropy ratio detail", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestInspectGrokEncryptedContent_RejectsInvalidBase64Length(t *testing.T) {
|
|
_, err := InspectGrokEncryptedContent("AAAAA")
|
|
if err == nil {
|
|
t.Fatal("expected invalid base64 length to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "base64 decode failed") {
|
|
t.Fatalf("error = %q, want base64 decode detail", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestByteEntropyRatio_SingleByteReturnsZero(t *testing.T) {
|
|
if got := byteEntropyRatio([]byte{0xa5}); got != 0 {
|
|
t.Fatalf("byteEntropyRatio(single byte) = %v, want 0", got)
|
|
}
|
|
}
|
|
|
|
func testGeminiThoughtSignatureEnvelope() string {
|
|
payload := []byte{0x01, 0x0c}
|
|
for i := 0; i < 97; i++ {
|
|
payload = append(payload, byte(i))
|
|
}
|
|
inner := []byte{0x0a, byte(len(payload))}
|
|
inner = append(inner, payload...)
|
|
outer := []byte{0x12, byte(len(inner))}
|
|
outer = append(outer, inner...)
|
|
return base64.RawStdEncoding.EncodeToString(outer)
|
|
}
|
|
|
|
func testGemini25Field1ThoughtSignatureEnvelope() string {
|
|
payload := []byte{0x01}
|
|
for i := 0; len(payload) < 128; i++ {
|
|
payload = append(payload, byte((i*37+11)%251))
|
|
}
|
|
|
|
var decoded []byte
|
|
decoded = protowire.AppendTag(decoded, 1, protowire.BytesType)
|
|
decoded = protowire.AppendBytes(decoded, payload)
|
|
return base64.RawStdEncoding.EncodeToString(decoded)
|
|
}
|
|
|
|
func testUnpaddedClaudeThinkingSignature() string {
|
|
return testClaudeThinkingSignatureWithOpaqueLen(35)
|
|
}
|
|
|
|
// testUnpaddedClaudeCAISSignature builds a CAIS signature whose base64 form
|
|
// carries no "=" padding, which is the shape that used to slip past the Grok
|
|
// unpadded-base64 gate. The model text length is varied because padding depends
|
|
// on the encoded payload length.
|
|
func testUnpaddedClaudeCAISSignature() string {
|
|
for suffix := 0; suffix < 8; suffix++ {
|
|
parts := defaultClaudeCAISParts("claude-opus-5" + strings.Repeat("x", suffix))
|
|
if sample := parts.encode(); !strings.Contains(sample, "=") {
|
|
return sample
|
|
}
|
|
}
|
|
panic("could not build an unpadded Claude CAIS fixture")
|
|
}
|
|
|
|
func testUnpaddedAntigravityClaudeThinkingSignature() string {
|
|
return base64.StdEncoding.EncodeToString([]byte(testClaudeThinkingSignatureWithOpaqueLen(41)))
|
|
}
|
|
|
|
func testClaudeThinkingSignatureWithOpaqueLen(opaqueLen int) string {
|
|
var channelBlock []byte
|
|
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, 12)
|
|
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
|
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
|
channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
|
|
|
|
var container []byte
|
|
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
|
container = protowire.AppendBytes(container, channelBlock)
|
|
|
|
var payload []byte
|
|
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
|
payload = protowire.AppendBytes(payload, container)
|
|
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
|
payload = protowire.AppendVarint(payload, 1)
|
|
payload = protowire.AppendTag(payload, 4, protowire.BytesType)
|
|
opaque := make([]byte, 0, opaqueLen)
|
|
for i := 0; len(opaque) < opaqueLen; i++ {
|
|
opaque = append(opaque, byte((i*41+17)%251))
|
|
}
|
|
payload = protowire.AppendBytes(payload, opaque)
|
|
return base64.StdEncoding.EncodeToString(payload)
|
|
}
|
|
|
|
func grokEncryptedContentSamplesPath() (string, bool) {
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
repo := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
|
path := filepath.Join(repo, "docs", "native-prompt-capture", "corpus", "grok-encrypted-content", "samples.json")
|
|
if _, err := os.Stat(path); err != nil {
|
|
return path, false
|
|
}
|
|
return path, true
|
|
}
|
|
|
|
func TestSignatureProviderFromModelName_Grok(t *testing.T) {
|
|
for _, model := range []string{"grok-4.5", "grok-4.5-build", "grok-composer-2.5-fast", "grok-code-fast-1"} {
|
|
t.Run(model, func(t *testing.T) {
|
|
if got := SignatureProviderFromModelName(model); got != SignatureProviderGrok {
|
|
t.Errorf("SignatureProviderFromModelName(%q) = %q, want %q", model, got, SignatureProviderGrok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDetectSignatureProvider_NeverClassifiesGrok pins the contract that xAI is
|
|
// a target-only family. Its ciphertext carries no envelope, no version byte and
|
|
// no fixed length, so a positive detection rule would necessarily also claim
|
|
// unrelated opaque payloads. Callers establish an xAI target from provenance and
|
|
// then use InspectGrokEncryptedContent as a replay-safety check.
|
|
func TestDetectSignatureProvider_NeverClassifiesGrok(t *testing.T) {
|
|
path, ok := grokEncryptedContentSamplesPath()
|
|
if !ok {
|
|
t.Skip("grok encrypted_content corpus missing; run docs/native-prompt-capture/scripts/harvest-grok-encrypted-content.sh")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read grok corpus: %v", err)
|
|
}
|
|
var samples []string
|
|
if err := json.Unmarshal(raw, &samples); err != nil {
|
|
var wrapped struct {
|
|
Samples []string `json:"samples"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
|
t.Fatalf("parse grok corpus: %v", err)
|
|
}
|
|
samples = wrapped.Samples
|
|
}
|
|
if len(samples) == 0 {
|
|
t.Skip("grok encrypted_content corpus is empty")
|
|
}
|
|
for _, sig := range samples {
|
|
if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider = %q, want %q for native encrypted_content", got, SignatureProviderUnknown)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDecideSignatureCompatibility_GrokDropsBlock contrasts with the Kimi
|
|
// policy: xAI decrypts the blob and answers 400 for foreign or mutated input, so
|
|
// an incompatible block cannot survive by shedding just its signature.
|
|
func TestDecideSignatureCompatibility_GrokDropsBlock(t *testing.T) {
|
|
decision := DecideSignatureCompatibility(SignatureProviderGrok, observedFable5Sample, SignatureBlockKindUnknown)
|
|
if decision.Compatible {
|
|
t.Fatalf("Claude signature reported compatible with a Grok target")
|
|
}
|
|
if decision.Action != SignatureActionDropBlock {
|
|
t.Errorf("Action = %q, want %q", decision.Action, SignatureActionDropBlock)
|
|
}
|
|
}
|