feat: v3 streaming with LTHN rolling keys and configurable cadence

V3 streaming format enables zero-trust media streaming:
- Content encrypted once with random CEK
- CEK wrapped with time-bound stream keys derived from LTHN hash
- Rolling window: current period + next period always valid
- Keys auto-expire, no revocation needed

Cadence options (platform controls refresh rate):
- daily:  24-hour periods (2026-01-12)
- 12h:    Half-day periods (2026-01-12-AM/PM)
- 6h:     Quarter-day periods (2026-01-12-00/06/12/18)
- 1h:     Hourly periods (2026-01-12-15)

Key derivation: SHA256(LTHN(period:license:fingerprint))
- LTHN is rainbow-table resistant (salt derived from input)
- Only the derived key can decrypt, never transmitted

New files:
- pkg/smsg/stream.go - v3 encryption/decryption
- pkg/smsg/stream_test.go - 17 tests including cadence

WASM v1.3.0:
- BorgSMSG.decryptV3(data, {license, fingerprint})
- getInfo() now returns cadence and keyMethod
This commit is contained in:
snider 2026-01-12 16:01:59 +00:00
parent 0ba0897c25
commit 2debed53f1
7 changed files with 1067 additions and 3 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

502
pkg/smsg/stream.go Normal file
View file

@ -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
}

381
pkg/smsg/stream_test.go Normal file
View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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{}{