diff --git a/demo/stmf.wasm b/demo/stmf.wasm index f029e7c..7862a10 100755 Binary files a/demo/stmf.wasm and b/demo/stmf.wasm differ diff --git a/js/borg-stmf/stmf.wasm b/js/borg-stmf/stmf.wasm index f029e7c..7862a10 100755 Binary files a/js/borg-stmf/stmf.wasm and b/js/borg-stmf/stmf.wasm differ diff --git a/pkg/player/frontend/stmf.wasm b/pkg/player/frontend/stmf.wasm index f029e7c..7862a10 100755 Binary files a/pkg/player/frontend/stmf.wasm and b/pkg/player/frontend/stmf.wasm differ diff --git a/pkg/smsg/stream.go b/pkg/smsg/stream.go new file mode 100644 index 0000000..ad39df4 --- /dev/null +++ b/pkg/smsg/stream.go @@ -0,0 +1,502 @@ +package smsg + +// V3 Streaming Support with LTHN Rolling Keys +// +// This file implements zero-trust streaming where: +// - Content is encrypted once with a random CEK (Content Encryption Key) +// - CEK is wrapped (encrypted) with time-bound stream keys +// - Stream keys are derived using LTHN(date:license:fingerprint) +// - Rolling window: today and tomorrow keys are valid (24-48hr window) +// - Keys auto-expire - no revocation needed +// +// Server flow: +// 1. Generate random CEK +// 2. Encrypt content with CEK +// 3. For today & tomorrow: wrap CEK with DeriveStreamKey(date, license, fingerprint) +// 4. Store wrapped keys in header +// +// Client flow: +// 1. Derive stream key for today (or tomorrow) +// 2. Try to unwrap CEK from header +// 3. Decrypt content with CEK + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "time" + + "github.com/Snider/Enchantrix/pkg/crypt" + "github.com/Snider/Enchantrix/pkg/enchantrix" + "github.com/Snider/Enchantrix/pkg/trix" +) + +// StreamParams contains the parameters needed for stream key derivation +type StreamParams struct { + License string // User's license identifier + Fingerprint string // Device/session fingerprint + Cadence Cadence // Key rotation cadence (default: daily) +} + +// DeriveStreamKey derives a 32-byte ChaCha key from date, license, and fingerprint. +// Uses LTHN hash which is rainbow-table resistant (salt derived from input itself). +// +// The derived key is: SHA256(LTHN("YYYY-MM-DD:license:fingerprint")) +func DeriveStreamKey(date, license, fingerprint string) []byte { + // Build input string + input := fmt.Sprintf("%s:%s:%s", date, license, fingerprint) + + // Use Enchantrix crypt service for LTHN hash + cryptService := crypt.NewService() + lthnHash := cryptService.Hash(crypt.LTHN, input) + + // LTHN returns hex string, hash it again to get 32 bytes for ChaCha + key := sha256.Sum256([]byte(lthnHash)) + return key[:] +} + +// GetRollingDates returns today and tomorrow's date strings in YYYY-MM-DD format +// This is the default daily cadence. +func GetRollingDates() (current, next string) { + return GetRollingPeriods(CadenceDaily, time.Now().UTC()) +} + +// GetRollingDatesAt returns today and tomorrow relative to a specific time +func GetRollingDatesAt(t time.Time) (current, next string) { + return GetRollingPeriods(CadenceDaily, t.UTC()) +} + +// GetRollingPeriods returns the current and next period strings based on cadence. +// The period string format varies by cadence: +// - daily: "2006-01-02" +// - 12h: "2006-01-02-AM" or "2006-01-02-PM" +// - 6h: "2006-01-02-00", "2006-01-02-06", "2006-01-02-12", "2006-01-02-18" +// - 1h: "2006-01-02-15" (hour in 24h format) +func GetRollingPeriods(cadence Cadence, t time.Time) (current, next string) { + t = t.UTC() + + switch cadence { + case CadenceHalfDay: + // 12-hour periods: AM (00:00-11:59) and PM (12:00-23:59) + date := t.Format("2006-01-02") + if t.Hour() < 12 { + current = date + "-AM" + next = date + "-PM" + } else { + current = date + "-PM" + next = t.AddDate(0, 0, 1).Format("2006-01-02") + "-AM" + } + + case CadenceQuarter: + // 6-hour periods: 00, 06, 12, 18 + date := t.Format("2006-01-02") + hour := t.Hour() + period := (hour / 6) * 6 + nextPeriod := period + 6 + + current = fmt.Sprintf("%s-%02d", date, period) + if nextPeriod >= 24 { + next = fmt.Sprintf("%s-%02d", t.AddDate(0, 0, 1).Format("2006-01-02"), 0) + } else { + next = fmt.Sprintf("%s-%02d", date, nextPeriod) + } + + case CadenceHourly: + // Hourly periods + current = t.Format("2006-01-02-15") + next = t.Add(time.Hour).Format("2006-01-02-15") + + default: // CadenceDaily or empty + current = t.Format("2006-01-02") + next = t.AddDate(0, 0, 1).Format("2006-01-02") + } + + return +} + +// GetCadenceWindowDuration returns the duration of one period for a cadence +func GetCadenceWindowDuration(cadence Cadence) time.Duration { + switch cadence { + case CadenceHourly: + return time.Hour + case CadenceQuarter: + return 6 * time.Hour + case CadenceHalfDay: + return 12 * time.Hour + default: // CadenceDaily + return 24 * time.Hour + } +} + +// WrapCEK wraps a Content Encryption Key with a stream key +// Returns base64-encoded wrapped key (includes nonce) +func WrapCEK(cek, streamKey []byte) (string, error) { + sigil, err := enchantrix.NewChaChaPolySigil(streamKey) + if err != nil { + return "", fmt.Errorf("failed to create sigil: %w", err) + } + + wrapped, err := sigil.In(cek) + if err != nil { + return "", fmt.Errorf("failed to wrap CEK: %w", err) + } + + return base64.StdEncoding.EncodeToString(wrapped), nil +} + +// UnwrapCEK unwraps a Content Encryption Key using a stream key +// Takes base64-encoded wrapped key, returns raw CEK bytes +func UnwrapCEK(wrappedB64 string, streamKey []byte) ([]byte, error) { + wrapped, err := base64.StdEncoding.DecodeString(wrappedB64) + if err != nil { + return nil, fmt.Errorf("failed to decode wrapped key: %w", err) + } + + sigil, err := enchantrix.NewChaChaPolySigil(streamKey) + if err != nil { + return nil, fmt.Errorf("failed to create sigil: %w", err) + } + + cek, err := sigil.Out(wrapped) + if err != nil { + return nil, ErrDecryptionFailed + } + + return cek, nil +} + +// GenerateCEK generates a random 32-byte Content Encryption Key +func GenerateCEK() ([]byte, error) { + cek := make([]byte, 32) + if _, err := rand.Read(cek); err != nil { + return nil, fmt.Errorf("failed to generate CEK: %w", err) + } + return cek, nil +} + +// EncryptV3 encrypts a message using v3 streaming format with rolling keys. +// The content is encrypted with a random CEK, which is then wrapped with +// stream keys for today and tomorrow. +func EncryptV3(msg *Message, params *StreamParams, manifest *Manifest) ([]byte, error) { + if params == nil || params.License == "" { + return nil, ErrLicenseRequired + } + if msg.Body == "" && len(msg.Attachments) == 0 { + return nil, ErrEmptyMessage + } + + // Set timestamp if not set + if msg.Timestamp == 0 { + msg.Timestamp = time.Now().Unix() + } + + // Generate random CEK + cek, err := GenerateCEK() + if err != nil { + return nil, err + } + + // Determine cadence (default to daily if not specified) + cadence := params.Cadence + if cadence == "" { + cadence = CadenceDaily + } + + // Get rolling periods based on cadence + current, next := GetRollingPeriods(cadence, time.Now().UTC()) + + // Wrap CEK with current period's stream key + currentKey := DeriveStreamKey(current, params.License, params.Fingerprint) + wrappedCurrent, err := WrapCEK(cek, currentKey) + if err != nil { + return nil, fmt.Errorf("failed to wrap CEK for current period: %w", err) + } + + // Wrap CEK with next period's stream key + nextKey := DeriveStreamKey(next, params.License, params.Fingerprint) + wrappedNext, err := WrapCEK(cek, nextKey) + if err != nil { + return nil, fmt.Errorf("failed to wrap CEK for next period: %w", err) + } + + // Build v3 payload (similar to v2 but encrypted with CEK) + payload, attachmentData, err := buildV3Payload(msg) + if err != nil { + return nil, err + } + + // Compress payload + compressed, err := zstdCompress(payload) + if err != nil { + return nil, fmt.Errorf("compression failed: %w", err) + } + + // Encrypt with CEK + sigil, err := enchantrix.NewChaChaPolySigil(cek) + if err != nil { + return nil, fmt.Errorf("failed to create sigil: %w", err) + } + + encrypted, err := sigil.In(compressed) + if err != nil { + return nil, fmt.Errorf("encryption failed: %w", err) + } + + // Encrypt attachment data with CEK + encryptedAttachments, err := sigil.In(attachmentData) + if err != nil { + return nil, fmt.Errorf("attachment encryption failed: %w", err) + } + + // Create header with wrapped keys + headerMap := map[string]interface{}{ + "version": Version, + "algorithm": "chacha20poly1305", + "format": FormatV3, + "compression": CompressionZstd, + "keyMethod": KeyMethodLTHNRolling, + "cadence": string(cadence), + "wrappedKeys": []WrappedKey{ + {Date: current, Wrapped: wrappedCurrent}, + {Date: next, Wrapped: wrappedNext}, + }, + } + + if manifest != nil { + if manifest.IssuedAt == 0 { + manifest.IssuedAt = time.Now().Unix() + } + headerMap["manifest"] = manifest + } + + // Build v3 binary format: [4-byte json len][json header][encrypted payload][encrypted attachments] + headerJSON, err := json.Marshal(headerMap) + if err != nil { + return nil, fmt.Errorf("failed to marshal header: %w", err) + } + + // Calculate total size + totalSize := 4 + len(headerJSON) + 4 + len(encrypted) + len(encryptedAttachments) + output := make([]byte, 0, totalSize) + + // Write header length (4 bytes, big-endian) + headerLen := make([]byte, 4) + binary.BigEndian.PutUint32(headerLen, uint32(len(headerJSON))) + output = append(output, headerLen...) + + // Write header JSON + output = append(output, headerJSON...) + + // Write encrypted payload length (4 bytes, big-endian) + payloadLen := make([]byte, 4) + binary.BigEndian.PutUint32(payloadLen, uint32(len(encrypted))) + output = append(output, payloadLen...) + + // Write encrypted payload + output = append(output, encrypted...) + + // Write encrypted attachments + output = append(output, encryptedAttachments...) + + // Wrap in trix container + t := &trix.Trix{ + Header: headerMap, + Payload: output, + } + + return trix.Encode(t, Magic, nil) +} + +// DecryptV3 decrypts a v3 streaming message using rolling keys. +// It tries today's key first, then tomorrow's key. +func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { + if params == nil || params.License == "" { + return nil, nil, ErrLicenseRequired + } + + // Decode trix container + t, err := trix.Decode(data, Magic, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode container: %w", err) + } + + // Parse header + headerJSON, err := json.Marshal(t.Header) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal header: %w", err) + } + + var header Header + if err := json.Unmarshal(headerJSON, &header); err != nil { + return nil, nil, fmt.Errorf("failed to parse header: %w", err) + } + + // Verify v3 format + if header.Format != FormatV3 { + return nil, nil, fmt.Errorf("expected v3 format, got: %s", header.Format) + } + + if header.KeyMethod != KeyMethodLTHNRolling { + return nil, nil, fmt.Errorf("unsupported key method: %s", header.KeyMethod) + } + + // Determine cadence from header (or use params, or default to daily) + cadence := header.Cadence + if cadence == "" && params.Cadence != "" { + cadence = params.Cadence + } + if cadence == "" { + cadence = CadenceDaily + } + + // Try to unwrap CEK with rolling keys + cek, err := tryUnwrapCEK(header.WrappedKeys, params, cadence) + if err != nil { + return nil, &header, err + } + + // Parse v3 binary payload + payload := t.Payload + if len(payload) < 8 { + return nil, &header, ErrInvalidPayload + } + + // Read header length (skip - we already parsed from trix header) + headerLen := binary.BigEndian.Uint32(payload[:4]) + pos := 4 + int(headerLen) + + if len(payload) < pos+4 { + return nil, &header, ErrInvalidPayload + } + + // Read encrypted payload length + encryptedLen := binary.BigEndian.Uint32(payload[pos : pos+4]) + pos += 4 + + if len(payload) < pos+int(encryptedLen) { + return nil, &header, ErrInvalidPayload + } + + // Extract encrypted payload and attachments + encryptedPayload := payload[pos : pos+int(encryptedLen)] + encryptedAttachments := payload[pos+int(encryptedLen):] + + // Decrypt with CEK + sigil, err := enchantrix.NewChaChaPolySigil(cek) + if err != nil { + return nil, &header, fmt.Errorf("failed to create sigil: %w", err) + } + + compressed, err := sigil.Out(encryptedPayload) + if err != nil { + return nil, &header, ErrDecryptionFailed + } + + // Decompress + var decompressed []byte + if header.Compression == CompressionZstd { + decompressed, err = zstdDecompress(compressed) + if err != nil { + return nil, &header, fmt.Errorf("decompression failed: %w", err) + } + } else { + decompressed = compressed + } + + // Parse message + var msg Message + if err := json.Unmarshal(decompressed, &msg); err != nil { + return nil, &header, fmt.Errorf("failed to parse message: %w", err) + } + + // Decrypt attachments if present + if len(encryptedAttachments) > 0 { + attachmentData, err := sigil.Out(encryptedAttachments) + if err != nil { + return nil, &header, fmt.Errorf("attachment decryption failed: %w", err) + } + + // Restore attachment content from binary data + if err := restoreV3Attachments(&msg, attachmentData); err != nil { + return nil, &header, err + } + } + + return &msg, &header, nil +} + +// tryUnwrapCEK attempts to unwrap the CEK using current or next period's key +func tryUnwrapCEK(wrappedKeys []WrappedKey, params *StreamParams, cadence Cadence) ([]byte, error) { + current, next := GetRollingPeriods(cadence, time.Now().UTC()) + + // Build map of available wrapped keys by period + keysByPeriod := make(map[string]string) + for _, wk := range wrappedKeys { + keysByPeriod[wk.Date] = wk.Wrapped + } + + // Try current period's key first + if wrapped, ok := keysByPeriod[current]; ok { + streamKey := DeriveStreamKey(current, params.License, params.Fingerprint) + if cek, err := UnwrapCEK(wrapped, streamKey); err == nil { + return cek, nil + } + } + + // Try next period's key + if wrapped, ok := keysByPeriod[next]; ok { + streamKey := DeriveStreamKey(next, params.License, params.Fingerprint) + if cek, err := UnwrapCEK(wrapped, streamKey); err == nil { + return cek, nil + } + } + + return nil, ErrNoValidKey +} + +// buildV3Payload builds the message JSON and binary attachment data +func buildV3Payload(msg *Message) ([]byte, []byte, error) { + // Create a copy of the message without attachment content + msgCopy := *msg + var attachmentData []byte + + for i := range msgCopy.Attachments { + att := &msgCopy.Attachments[i] + if att.Content != "" { + // Decode base64 content to binary + data, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode attachment %s: %w", att.Name, err) + } + attachmentData = append(attachmentData, data...) + att.Content = "" // Clear content, will be restored on decrypt + } + } + + // Marshal message (without attachment content) + payload, err := json.Marshal(&msgCopy) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal message: %w", err) + } + + return payload, attachmentData, nil +} + +// restoreV3Attachments restores attachment content from decrypted binary data +func restoreV3Attachments(msg *Message, data []byte) error { + offset := 0 + for i := range msg.Attachments { + att := &msg.Attachments[i] + if att.Size > 0 { + if offset+att.Size > len(data) { + return fmt.Errorf("attachment data truncated for %s", att.Name) + } + att.Content = base64.StdEncoding.EncodeToString(data[offset : offset+att.Size]) + offset += att.Size + } + } + return nil +} diff --git a/pkg/smsg/stream_test.go b/pkg/smsg/stream_test.go new file mode 100644 index 0000000..18311b5 --- /dev/null +++ b/pkg/smsg/stream_test.go @@ -0,0 +1,381 @@ +package smsg + +import ( + "testing" + "time" +) + +func TestDeriveStreamKey(t *testing.T) { + // Test that same inputs produce same key + key1 := DeriveStreamKey("2026-01-12", "license123", "fingerprint456") + key2 := DeriveStreamKey("2026-01-12", "license123", "fingerprint456") + + if len(key1) != 32 { + t.Errorf("Key length = %d, want 32", len(key1)) + } + + if string(key1) != string(key2) { + t.Error("Same inputs should produce same key") + } + + // Test that different dates produce different keys + key3 := DeriveStreamKey("2026-01-13", "license123", "fingerprint456") + if string(key1) == string(key3) { + t.Error("Different dates should produce different keys") + } + + // Test that different licenses produce different keys + key4 := DeriveStreamKey("2026-01-12", "license789", "fingerprint456") + if string(key1) == string(key4) { + t.Error("Different licenses should produce different keys") + } +} + +func TestGetRollingDates(t *testing.T) { + today, tomorrow := GetRollingDates() + + // Parse dates to verify format + todayTime, err := time.Parse("2006-01-02", today) + if err != nil { + t.Fatalf("Invalid today format: %v", err) + } + + tomorrowTime, err := time.Parse("2006-01-02", tomorrow) + if err != nil { + t.Fatalf("Invalid tomorrow format: %v", err) + } + + // Tomorrow should be 1 day after today + diff := tomorrowTime.Sub(todayTime) + if diff != 24*time.Hour { + t.Errorf("Tomorrow should be 24h after today, got %v", diff) + } +} + +func TestWrapUnwrapCEK(t *testing.T) { + // Generate a test CEK + cek, err := GenerateCEK() + if err != nil { + t.Fatalf("GenerateCEK failed: %v", err) + } + + // Generate a stream key + streamKey := DeriveStreamKey("2026-01-12", "test-license", "test-fp") + + // Wrap CEK + wrapped, err := WrapCEK(cek, streamKey) + if err != nil { + t.Fatalf("WrapCEK failed: %v", err) + } + + // Unwrap CEK + unwrapped, err := UnwrapCEK(wrapped, streamKey) + if err != nil { + t.Fatalf("UnwrapCEK failed: %v", err) + } + + // Verify CEK matches + if string(cek) != string(unwrapped) { + t.Error("Unwrapped CEK doesn't match original") + } + + // Wrong key should fail + wrongKey := DeriveStreamKey("2026-01-12", "wrong-license", "test-fp") + _, err = UnwrapCEK(wrapped, wrongKey) + if err == nil { + t.Error("UnwrapCEK with wrong key should fail") + } +} + +func TestEncryptDecryptV3RoundTrip(t *testing.T) { + msg := NewMessage("Hello, this is a v3 streaming message!"). + WithSubject("V3 Test"). + WithFrom("stream@dapp.fm") + + params := &StreamParams{ + License: "test-license-123", + Fingerprint: "device-fp-456", + } + + manifest := NewManifest("Test Track") + manifest.Artist = "Test Artist" + manifest.LicenseType = "stream" + + // Encrypt + encrypted, err := EncryptV3(msg, params, manifest) + if err != nil { + t.Fatalf("EncryptV3 failed: %v", err) + } + + // Decrypt with same params + decrypted, header, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 failed: %v", err) + } + + // Verify message content + if decrypted.Body != msg.Body { + t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body) + } + if decrypted.Subject != msg.Subject { + t.Errorf("Subject = %q, want %q", decrypted.Subject, msg.Subject) + } + + // Verify header + if header.Format != FormatV3 { + t.Errorf("Format = %q, want %q", header.Format, FormatV3) + } + if header.KeyMethod != KeyMethodLTHNRolling { + t.Errorf("KeyMethod = %q, want %q", header.KeyMethod, KeyMethodLTHNRolling) + } + if len(header.WrappedKeys) != 2 { + t.Errorf("WrappedKeys count = %d, want 2", len(header.WrappedKeys)) + } + + // Verify manifest + if header.Manifest == nil { + t.Fatal("Manifest is nil") + } + if header.Manifest.Title != "Test Track" { + t.Errorf("Manifest.Title = %q, want %q", header.Manifest.Title, "Test Track") + } +} + +func TestDecryptV3WrongLicense(t *testing.T) { + msg := NewMessage("Secret content") + + params := &StreamParams{ + License: "correct-license", + Fingerprint: "device-fp", + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 failed: %v", err) + } + + // Try to decrypt with wrong license + wrongParams := &StreamParams{ + License: "wrong-license", + Fingerprint: "device-fp", + } + + _, _, err = DecryptV3(encrypted, wrongParams) + if err == nil { + t.Error("DecryptV3 with wrong license should fail") + } + if err != ErrNoValidKey { + t.Errorf("Error = %v, want ErrNoValidKey", err) + } +} + +func TestDecryptV3WrongFingerprint(t *testing.T) { + msg := NewMessage("Secret content") + + params := &StreamParams{ + License: "test-license", + Fingerprint: "correct-fingerprint", + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 failed: %v", err) + } + + // Try to decrypt with wrong fingerprint + wrongParams := &StreamParams{ + License: "test-license", + Fingerprint: "wrong-fingerprint", + } + + _, _, err = DecryptV3(encrypted, wrongParams) + if err == nil { + t.Error("DecryptV3 with wrong fingerprint should fail") + } +} + +func TestEncryptV3WithAttachment(t *testing.T) { + msg := NewMessage("Message with attachment") + msg.AddBinaryAttachment("test.mp3", []byte("fake audio data here"), "audio/mpeg") + + params := &StreamParams{ + License: "test-license", + Fingerprint: "test-fp", + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 failed: %v", err) + } + + decrypted, _, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 failed: %v", err) + } + + // Verify attachment + if len(decrypted.Attachments) != 1 { + t.Fatalf("Attachment count = %d, want 1", len(decrypted.Attachments)) + } + + att := decrypted.GetAttachment("test.mp3") + if att == nil { + t.Fatal("Attachment not found") + } + if att.MimeType != "audio/mpeg" { + t.Errorf("MimeType = %q, want %q", att.MimeType, "audio/mpeg") + } +} + +func TestEncryptV3RequiresLicense(t *testing.T) { + msg := NewMessage("Test") + + // Nil params + _, err := EncryptV3(msg, nil, nil) + if err != ErrLicenseRequired { + t.Errorf("Error = %v, want ErrLicenseRequired", err) + } + + // Empty license + _, err = EncryptV3(msg, &StreamParams{}, nil) + if err != ErrLicenseRequired { + t.Errorf("Error = %v, want ErrLicenseRequired", err) + } +} + +func TestCadencePeriods(t *testing.T) { + // Test at a known time: 2026-01-12 15:30:00 UTC + testTime := time.Date(2026, 1, 12, 15, 30, 0, 0, time.UTC) + + tests := []struct { + cadence Cadence + expectedCurrent string + expectedNext string + }{ + {CadenceDaily, "2026-01-12", "2026-01-13"}, + {CadenceHalfDay, "2026-01-12-PM", "2026-01-13-AM"}, + {CadenceQuarter, "2026-01-12-12", "2026-01-12-18"}, + {CadenceHourly, "2026-01-12-15", "2026-01-12-16"}, + } + + for _, tc := range tests { + t.Run(string(tc.cadence), func(t *testing.T) { + current, next := GetRollingPeriods(tc.cadence, testTime) + if current != tc.expectedCurrent { + t.Errorf("current = %q, want %q", current, tc.expectedCurrent) + } + if next != tc.expectedNext { + t.Errorf("next = %q, want %q", next, tc.expectedNext) + } + }) + } +} + +func TestCadenceHalfDayAM(t *testing.T) { + // Test in the morning + testTime := time.Date(2026, 1, 12, 9, 0, 0, 0, time.UTC) + current, next := GetRollingPeriods(CadenceHalfDay, testTime) + + if current != "2026-01-12-AM" { + t.Errorf("current = %q, want %q", current, "2026-01-12-AM") + } + if next != "2026-01-12-PM" { + t.Errorf("next = %q, want %q", next, "2026-01-12-PM") + } +} + +func TestCadenceQuarterBoundary(t *testing.T) { + // Test at 23:00 - should wrap to next day + testTime := time.Date(2026, 1, 12, 23, 0, 0, 0, time.UTC) + current, next := GetRollingPeriods(CadenceQuarter, testTime) + + if current != "2026-01-12-18" { + t.Errorf("current = %q, want %q", current, "2026-01-12-18") + } + if next != "2026-01-13-00" { + t.Errorf("next = %q, want %q", next, "2026-01-13-00") + } +} + +func TestEncryptDecryptV3WithCadence(t *testing.T) { + cadences := []Cadence{CadenceDaily, CadenceHalfDay, CadenceQuarter, CadenceHourly} + + for _, cadence := range cadences { + t.Run(string(cadence), func(t *testing.T) { + msg := NewMessage("Testing " + string(cadence) + " cadence") + + params := &StreamParams{ + License: "cadence-test-license", + Fingerprint: "cadence-test-fp", + Cadence: cadence, + } + + // Encrypt + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 failed: %v", err) + } + + // Decrypt with same params + decrypted, header, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 failed: %v", err) + } + + if decrypted.Body != msg.Body { + t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body) + } + + // Verify cadence in header + if header.Cadence != cadence { + t.Errorf("Cadence = %q, want %q", header.Cadence, cadence) + } + }) + } +} + +func TestRollingKeyWindow(t *testing.T) { + // This test verifies that both today's and tomorrow's keys work + msg := NewMessage("Rolling window test") + + // Create params + params := &StreamParams{ + License: "rolling-test-license", + Fingerprint: "rolling-test-fp", + } + + // Encrypt with current time + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 failed: %v", err) + } + + // Should decrypt successfully (within rolling window) + decrypted, header, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 failed: %v", err) + } + + if decrypted.Body != msg.Body { + t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body) + } + + // Verify we have both today and tomorrow keys + today, tomorrow := GetRollingDates() + hasToday := false + hasTomorrow := false + for _, wk := range header.WrappedKeys { + if wk.Date == today { + hasToday = true + } + if wk.Date == tomorrow { + hasTomorrow = true + } + } + if !hasToday { + t.Error("Missing today's wrapped key") + } + if !hasTomorrow { + t.Error("Missing tomorrow's wrapped key") + } +} diff --git a/pkg/smsg/types.go b/pkg/smsg/types.go index c15ec44..2b2d45b 100644 --- a/pkg/smsg/types.go +++ b/pkg/smsg/types.go @@ -31,6 +31,9 @@ var ( ErrDecryptionFailed = errors.New("decryption failed (wrong password?)") ErrPasswordRequired = errors.New("password is required") ErrEmptyMessage = errors.New("message cannot be empty") + ErrStreamKeyExpired = errors.New("stream key expired (outside rolling window)") + ErrNoValidKey = errors.New("no valid wrapped key found for current date") + ErrLicenseRequired = errors.New("license is required for stream decryption") ) // Attachment represents a file attached to the message @@ -286,6 +289,7 @@ func (m *Manifest) AddLink(platform, url string) *Manifest { const ( FormatV1 = "" // Original format: JSON with base64-encoded attachments FormatV2 = "v2" // Binary format: JSON header + raw binary attachments + FormatV3 = "v3" // Streaming format: CEK wrapped with rolling LTHN keys ) // Compression types @@ -295,12 +299,57 @@ const ( CompressionZstd = "zstd" // Zstandard compression (faster, better ratio) ) +// Key derivation methods for v3 streaming +const ( + // KeyMethodDirect uses password directly (v1/v2 behavior) + KeyMethodDirect = "" + + // KeyMethodLTHNRolling uses LTHN hash with rolling date windows + // Key = SHA256(LTHN(date:license:fingerprint)) + // Valid keys: current period and next period (rolling window) + KeyMethodLTHNRolling = "lthn-rolling" +) + +// Cadence defines how often stream keys rotate +type Cadence string + +const ( + // CadenceDaily rotates keys every 24 hours (default) + // Date format: "2006-01-02" + CadenceDaily Cadence = "daily" + + // CadenceHalfDay rotates keys every 12 hours + // Date format: "2006-01-02-AM" or "2006-01-02-PM" + CadenceHalfDay Cadence = "12h" + + // CadenceQuarter rotates keys every 6 hours + // Date format: "2006-01-02-00", "2006-01-02-06", "2006-01-02-12", "2006-01-02-18" + CadenceQuarter Cadence = "6h" + + // CadenceHourly rotates keys every hour + // Date format: "2006-01-02-15" (24-hour format) + CadenceHourly Cadence = "1h" +) + +// WrappedKey represents a CEK (Content Encryption Key) wrapped with a time-bound stream key. +// The stream key is derived from LTHN(date:license:fingerprint) and is never transmitted. +// Only the wrapped CEK (which includes its own nonce) is stored in the header. +type WrappedKey struct { + Date string `json:"date"` // ISO date "YYYY-MM-DD" for key derivation + Wrapped string `json:"wrapped"` // base64([nonce][ChaCha(CEK, streamKey)]) +} + // Header represents the SMSG container header type Header struct { Version string `json:"version"` Algorithm string `json:"algorithm"` - Format string `json:"format,omitempty"` // v2 for binary, empty for v1 (base64) - Compression string `json:"compression,omitempty"` // gzip or empty for none + Format string `json:"format,omitempty"` // v2 for binary, v3 for streaming, empty for v1 (base64) + Compression string `json:"compression,omitempty"` // gzip, zstd, or empty for none Hint string `json:"hint,omitempty"` // optional password hint Manifest *Manifest `json:"manifest,omitempty"` // public metadata for discovery + + // V3 streaming fields + KeyMethod string `json:"keyMethod,omitempty"` // lthn-rolling for v3 + Cadence Cadence `json:"cadence,omitempty"` // key rotation frequency (daily, 12h, 6h, 1h) + WrappedKeys []WrappedKey `json:"wrappedKeys,omitempty"` // CEK wrapped with rolling keys } diff --git a/pkg/wasm/stmf/main.go b/pkg/wasm/stmf/main.go index d6c32a2..184ac03 100644 --- a/pkg/wasm/stmf/main.go +++ b/pkg/wasm/stmf/main.go @@ -16,7 +16,7 @@ import ( ) // Version of the WASM module -const Version = "1.2.0" +const Version = "1.3.0" func main() { // Export the BorgSTMF object to JavaScript global scope @@ -32,6 +32,7 @@ func main() { js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{ "decrypt": js.FuncOf(smsgDecrypt), "decryptStream": js.FuncOf(smsgDecryptStream), + "decryptV3": js.FuncOf(smsgDecryptV3), // v3 streaming with rolling keys "encrypt": js.FuncOf(smsgEncrypt), "encryptWithManifest": js.FuncOf(smsgEncryptWithManifest), "getInfo": js.FuncOf(smsgGetInfo), @@ -495,6 +496,25 @@ func smsgGetInfo(this js.Value, args []js.Value) interface{} { result["hint"] = header.Hint } + // V3 streaming fields + if header.KeyMethod != "" { + result["keyMethod"] = header.KeyMethod + } + if header.Cadence != "" { + result["cadence"] = string(header.Cadence) + } + if len(header.WrappedKeys) > 0 { + wrappedKeys := make([]interface{}, len(header.WrappedKeys)) + for i, wk := range header.WrappedKeys { + wrappedKeys[i] = map[string]interface{}{ + "date": wk.Date, + // Note: wrapped key itself is not exposed for security + } + } + result["wrappedKeys"] = wrappedKeys + result["isV3Streaming"] = true + } + // Include manifest if present if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) @@ -626,6 +646,118 @@ func smsgQuickDecrypt(this js.Value, args []js.Value) interface{} { return promiseConstructor.New(handler) } +// smsgDecryptV3 decrypts a v3 streaming message using LTHN rolling keys. +// JavaScript usage: +// +// const result = await BorgSMSG.decryptV3(encryptedBase64, { +// license: 'user-license-id', +// fingerprint: 'device-fingerprint' +// }); +// // result.attachments[0].data is a Uint8Array +func smsgDecryptV3(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 2 { + reject.Invoke(newError("decryptV3 requires 2 arguments: encryptedBase64, {license, fingerprint}")) + return + } + + encryptedB64 := args[0].String() + paramsObj := args[1] + + // Extract stream params + license := paramsObj.Get("license").String() + fingerprint := "" + if !paramsObj.Get("fingerprint").IsUndefined() { + fingerprint = paramsObj.Get("fingerprint").String() + } + + if license == "" { + reject.Invoke(newError("license is required for v3 decryption")) + return + } + + params := &smsg.StreamParams{ + License: license, + Fingerprint: fingerprint, + } + + // Decode base64 + data, err := base64.StdEncoding.DecodeString(encryptedB64) + if err != nil { + reject.Invoke(newError("invalid base64: " + err.Error())) + return + } + + // Decrypt v3 + msg, header, err := smsg.DecryptV3(data, params) + if err != nil { + reject.Invoke(newError("v3 decryption failed: " + err.Error())) + return + } + + // Build result with binary attachment data + result := map[string]interface{}{ + "body": msg.Body, + "timestamp": msg.Timestamp, + } + + if msg.Subject != "" { + result["subject"] = msg.Subject + } + if msg.From != "" { + result["from"] = msg.From + } + + // Include header info + if header != nil { + result["header"] = map[string]interface{}{ + "format": header.Format, + "keyMethod": header.KeyMethod, + } + if header.Manifest != nil { + result["manifest"] = manifestToJS(header.Manifest) + } + } + + // Convert attachments with binary data + if len(msg.Attachments) > 0 { + attachments := make([]interface{}, len(msg.Attachments)) + for i, att := range msg.Attachments { + // Decode base64 to binary + data, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + reject.Invoke(newError("failed to decode attachment: " + err.Error())) + return + } + + // Create Uint8Array in JS + uint8Array := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(uint8Array, data) + + attachments[i] = map[string]interface{}{ + "name": att.Name, + "mime": att.MimeType, + "size": len(data), + "data": uint8Array, + } + } + result["attachments"] = attachments + } + + resolve.Invoke(js.ValueOf(result)) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + // messageToJS converts an smsg.Message to a JavaScript object func messageToJS(msg *smsg.Message) js.Value { result := map[string]interface{}{