Files
ipsw/pkg/img4/payload_test.go
blacktop 4f23763438 feat(extract): auto-decrypt remote kernelcaches via wiki keys
`ipsw download ipsw --kernel` now fetches firmware keys from
theapplewiki and decrypts encrypted kernelcaches inline. Unencrypted
members in the same IPSW pass through unchanged.

- pkg/img4: DecryptPayload reuses Payload.GetData for decompression,
  removing the duplicate LZSS/LZFSE branches.
- pkg/kernelcache: ParseImg4Data switches to img4.ParsePayload and
  exports ErrEncryptedKernelCache so callers can detect the missing-key
  case via errors.Is.
- internal/commands/extract: new keyed remote path with all-or-nothing
  preflight; the encryption-status peek lets unencrypted variants
  succeed even when the wiki has no entry for them.

  closes #1193
2026-04-27 15:57:23 -06:00

447 lines
12 KiB
Go

package img4
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPayloadCreation(t *testing.T) {
tests := []struct {
name string
config CreatePayloadConfig
expectError bool
}{
{
name: "basic uncompressed payload",
config: CreatePayloadConfig{
Type: "test",
Version: "Test Version 1.0",
Data: []byte("Hello, World!"),
},
expectError: false,
},
{
name: "payload with LZSS compression",
config: CreatePayloadConfig{
Type: "krnl",
Version: "Compressed Test",
Data: bytes.Repeat([]byte("test data "), 100),
Compression: "lzss",
},
expectError: false,
},
{
name: "payload with LZFSE compression",
config: CreatePayloadConfig{
Type: "logo",
Version: "LZFSE Test",
Data: bytes.Repeat([]byte("image data "), 100),
Compression: "lzfse",
},
expectError: false,
},
{
name: "payload with LZSS and extra data",
config: CreatePayloadConfig{
Type: "krnl",
Version: "Kernel with Extra",
Data: bytes.Repeat([]byte("kernel "), 200),
ExtraData: []byte("extra kernel metadata"),
Compression: "lzss",
},
expectError: false,
},
{
name: "payload with keybags",
config: CreatePayloadConfig{
Type: "sepi",
Version: "SEP Firmware",
Data: []byte("encrypted sep data"),
Keybags: []Keybag{
{
Type: PRODUCTION,
IV: generateTestData(16),
Key: generateTestData(32),
},
},
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload, err := CreatePayload(&tt.config)
if (err != nil) != tt.expectError {
t.Fatalf("CreatePayload() error = %v, expectError %v", err, tt.expectError)
}
if tt.expectError {
return
}
// Verify basic fields
if payload.Type != tt.config.Type {
t.Errorf("Type = %v, want %v", payload.Type, tt.config.Type)
}
if payload.Version != tt.config.Version {
t.Errorf("Version = %v, want %v", payload.Version, tt.config.Version)
}
// Verify compression (should NOT be present with extra data)
if len(tt.config.ExtraData) > 0 {
if payload.Compression.Algorithm != 0 || payload.Compression.UncompressedSize != 0 {
t.Errorf("Compression block should not be present when ExtraData is used")
}
} else if tt.config.Compression != "none" && tt.config.Compression != "" {
// Map string compression to enum for comparison
var expectedAlgo CompressionAlgorithm
switch strings.ToLower(tt.config.Compression) {
case "lzss":
expectedAlgo = CompressionAlgorithmLZSS
case "lzfse", "lzfse_iboot":
expectedAlgo = CompressionAlgorithmLZFSE
}
if payload.Compression.Algorithm != expectedAlgo {
t.Errorf("Compression.Algorithm = %v, want %v", payload.Compression.Algorithm, expectedAlgo)
}
if payload.Compression.UncompressedSize != len(tt.config.Data) {
t.Errorf("Compression.UncompressedSize = %v, want %v", payload.Compression.UncompressedSize, len(tt.config.Data))
}
}
// Verify keybags (may not be implemented in CreatePayload yet)
if len(tt.config.Keybags) > 0 {
t.Logf("Input keybags: %d, Output keybags: %d, Encrypted: %t",
len(tt.config.Keybags), len(payload.Keybags), payload.Encrypted)
// Note: Keybag support may not be fully implemented in CreatePayload
// This is a placeholder for when keybag creation is added
}
})
}
}
func TestDecryptPayloadDecompressesLZSSAfterDecrypt(t *testing.T) {
plaintext := append([]byte{0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01}, bytes.Repeat([]byte("kernel-data"), 512)...)
iv := bytes.Repeat([]byte{0x11}, aes.BlockSize)
key := bytes.Repeat([]byte{0x22}, 32)
payload, err := CreatePayload(&CreatePayloadConfig{
Type: IM4P_KERNELCACHE,
Version: "KernelCacheBuilder-test",
Data: plaintext,
Compression: "lzss",
Keybags: []Keybag{
{
Type: PRODUCTION,
IV: iv,
Key: key,
},
},
})
if err != nil {
t.Fatalf("CreatePayload() error = %v", err)
}
if rem := len(payload.Data) % aes.BlockSize; rem != 0 {
payload.Data = append(payload.Data, bytes.Repeat([]byte{0}, aes.BlockSize-rem)...)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("NewCipher() error = %v", err)
}
cipher.NewCBCEncrypter(block, iv).CryptBlocks(payload.Data, payload.Data)
data, err := payload.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
tmpDir := t.TempDir()
inPath := filepath.Join(tmpDir, "kernelcache.release.n66")
outPath := filepath.Join(tmpDir, "kernelcache.release.n66.dec")
if err := os.WriteFile(inPath, data, 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := DecryptPayload(inPath, outPath, iv, key); err != nil {
t.Fatalf("DecryptPayload() error = %v", err)
}
got, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("decrypted payload mismatch: got %d bytes with prefix %x, want %d bytes with prefix %x",
len(got), firstBytes(got, 8), len(plaintext), firstBytes(plaintext, 8))
}
}
func firstBytes(data []byte, count int) []byte {
if len(data) < count {
return data
}
return data[:count]
}
func TestPayloadRoundtrip(t *testing.T) {
tests := []struct {
name string
config CreatePayloadConfig
}{
{
name: "uncompressed roundtrip",
config: CreatePayloadConfig{
Type: "test",
Version: "Uncompressed",
Data: []byte("uncompressed data"),
},
},
{
name: "LZSS roundtrip",
config: CreatePayloadConfig{
Type: "krnl",
Version: "LZSS Kernel",
Data: bytes.Repeat([]byte("kernel data "), 50),
Compression: "lzss",
},
},
{
name: "LZFSE roundtrip",
config: CreatePayloadConfig{
Type: "logo",
Version: "LZFSE Logo",
Data: bytes.Repeat([]byte("image data "), 50),
Compression: "lzfse",
},
},
{
name: "LZSS with extra data",
config: CreatePayloadConfig{
Type: "krnl",
Version: "Kernel+Extra",
Data: bytes.Repeat([]byte("kernel "), 100),
ExtraData: []byte("kernel extra data"),
Compression: "lzss",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Step 1: Create payload
original, err := CreatePayload(&tt.config)
if err != nil {
t.Fatalf("CreatePayload() error = %v", err)
}
// Step 2: Marshal
data, err := original.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Step 3: Parse
parsed, err := ParsePayload(data)
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
// Step 4: Verify parsed matches original
if parsed.Type != original.Type {
t.Errorf("Parsed Type = %v, want %v", parsed.Type, original.Type)
}
if parsed.Version != original.Version {
t.Errorf("Parsed Version = %v, want %v", parsed.Version, original.Version)
}
// Step 5: Test decompression and data integrity
if tt.config.Compression != "none" && tt.config.Compression != "" {
// For compressed data, test decompression
decompressed, err := parsed.Decompress()
if err != nil {
t.Fatalf("Decompress() error = %v", err)
}
if !bytes.Equal(decompressed, tt.config.Data) {
t.Errorf("Decompressed data doesn't match original (got %d bytes, want %d bytes)",
len(decompressed), len(tt.config.Data))
}
// Test extra data
if len(tt.config.ExtraData) > 0 {
if !parsed.HasExtraData() {
t.Errorf("Expected extra data to be detected")
}
extraData := parsed.GetExtraData()
if !bytes.Equal(extraData, tt.config.ExtraData) {
t.Errorf("Extra data mismatch: got %d bytes, want %d bytes", len(extraData), len(tt.config.ExtraData))
}
}
} else {
// For uncompressed data, use GetData() which handles the uncompressed case
data, err := parsed.GetData()
if err != nil {
t.Fatalf("GetData() error for uncompressed data = %v", err)
}
// For uncompressed data, GetData() should return the original data without extra data
// Extra data is handled separately
if !bytes.Equal(data, tt.config.Data) {
t.Errorf("Uncompressed data doesn't match original (got %d bytes, want %d bytes)",
len(data), len(tt.config.Data))
}
// Test extra data if provided
if len(tt.config.ExtraData) > 0 {
if !parsed.HasExtraData() {
t.Errorf("Expected extra data to be detected for uncompressed payload")
}
extraData := parsed.GetExtraData()
if !bytes.Equal(extraData, tt.config.ExtraData) {
t.Errorf("Extra data mismatch for uncompressed: got %d bytes, want %d bytes", len(extraData), len(tt.config.ExtraData))
}
}
}
})
}
}
// TestPayloadWithRealData tests with real test files if available (CI-friendly)
func TestPayloadWithRealData(t *testing.T) {
testDataDir := "../../test-caches/TEST/22L572__AppleTV5,3"
// Check if test data exists (fail quietly for CI)
if _, err := os.Stat(testDataDir); os.IsNotExist(err) {
t.Skip("Test data not available, skipping real data test")
}
kernelPath := filepath.Join(testDataDir, "kernel")
extraPath := filepath.Join(testDataDir, "extra")
// Check if files exist
kernelData, err := os.ReadFile(kernelPath)
if err != nil {
t.Skip("Kernel test file not available")
}
extraData, err := os.ReadFile(extraPath)
if err != nil {
t.Skip("Extra test file not available")
}
t.Logf("Testing with real data: kernel=%d bytes, extra=%d bytes", len(kernelData), len(extraData))
// Test LZSS compression with real kernel data
payload, err := CreatePayload(&CreatePayloadConfig{
Type: "krnl",
Version: "Real Kernel Test",
Data: kernelData,
ExtraData: extraData,
Compression: "lzss",
})
if err != nil {
t.Fatalf("CreatePayload() with real data error = %v", err)
}
// Marshal and parse
data, err := payload.Marshal()
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
t.Logf("Compressed from %d bytes to %d bytes (%.1f%% compression)",
len(kernelData)+len(extraData), len(data),
100.0*float64(len(data))/float64(len(kernelData)+len(extraData)))
parsed, err := ParsePayload(data)
if err != nil {
t.Fatalf("ParsePayload() error = %v", err)
}
// Verify decompression
decompressed, err := parsed.Decompress()
if err != nil {
t.Fatalf("Decompress() error = %v", err)
}
if !bytes.Equal(decompressed, kernelData) {
t.Errorf("Decompressed kernel data doesn't match original (got %d bytes, want %d bytes)",
len(decompressed), len(kernelData))
}
// Verify extra data
if !parsed.HasExtraData() {
t.Error("Expected extra data to be detected")
}
extractedExtra := parsed.GetExtraData()
if !bytes.Equal(extractedExtra, extraData) {
t.Errorf("Extra data mismatch (got %d bytes, want %d bytes)",
len(extractedExtra), len(extraData))
}
}
func TestPayloadErrors(t *testing.T) {
// Test invalid ASN.1 data
_, err := ParsePayload([]byte("invalid asn1 data"))
if err == nil {
t.Error("Expected error for invalid ASN.1 data")
}
// Test empty data
_, err = ParsePayload([]byte{})
if err == nil {
t.Error("Expected error for empty data")
}
}
func BenchmarkPayloadCreation(b *testing.B) {
data := bytes.Repeat([]byte("benchmark data "), 100)
b.Run("uncompressed", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = CreatePayload(&CreatePayloadConfig{
Type: "test",
Version: "Benchmark",
Data: data,
})
}
})
b.Run("lzss", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = CreatePayload(&CreatePayloadConfig{
Type: "test",
Version: "Benchmark",
Data: data,
Compression: "lzss",
})
}
})
b.Run("lzfse", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = CreatePayload(&CreatePayloadConfig{
Type: "test",
Version: "Benchmark",
Data: data,
Compression: "lzfse",
})
}
})
}
// Helper function to generate deterministic test data
func generateTestData(size int) []byte {
data := make([]byte, size)
for i := range data {
data[i] = byte(i % 256)
}
return data
}