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:
parent
0ba0897c25
commit
2debed53f1
7 changed files with 1067 additions and 3 deletions
BIN
demo/stmf.wasm
BIN
demo/stmf.wasm
Binary file not shown.
Binary file not shown.
Binary file not shown.
502
pkg/smsg/stream.go
Normal file
502
pkg/smsg/stream.go
Normal 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
381
pkg/smsg/stream_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,9 @@ var (
|
||||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||||
ErrPasswordRequired = errors.New("password is required")
|
ErrPasswordRequired = errors.New("password is required")
|
||||||
ErrEmptyMessage = errors.New("message cannot be empty")
|
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
|
// Attachment represents a file attached to the message
|
||||||
|
|
@ -286,6 +289,7 @@ func (m *Manifest) AddLink(platform, url string) *Manifest {
|
||||||
const (
|
const (
|
||||||
FormatV1 = "" // Original format: JSON with base64-encoded attachments
|
FormatV1 = "" // Original format: JSON with base64-encoded attachments
|
||||||
FormatV2 = "v2" // Binary format: JSON header + raw binary attachments
|
FormatV2 = "v2" // Binary format: JSON header + raw binary attachments
|
||||||
|
FormatV3 = "v3" // Streaming format: CEK wrapped with rolling LTHN keys
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compression types
|
// Compression types
|
||||||
|
|
@ -295,12 +299,57 @@ const (
|
||||||
CompressionZstd = "zstd" // Zstandard compression (faster, better ratio)
|
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
|
// Header represents the SMSG container header
|
||||||
type Header struct {
|
type Header struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Algorithm string `json:"algorithm"`
|
Algorithm string `json:"algorithm"`
|
||||||
Format string `json:"format,omitempty"` // v2 for binary, empty for v1 (base64)
|
Format string `json:"format,omitempty"` // v2 for binary, v3 for streaming, empty for v1 (base64)
|
||||||
Compression string `json:"compression,omitempty"` // gzip or empty for none
|
Compression string `json:"compression,omitempty"` // gzip, zstd, or empty for none
|
||||||
Hint string `json:"hint,omitempty"` // optional password hint
|
Hint string `json:"hint,omitempty"` // optional password hint
|
||||||
Manifest *Manifest `json:"manifest,omitempty"` // public metadata for discovery
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version of the WASM module
|
// Version of the WASM module
|
||||||
const Version = "1.2.0"
|
const Version = "1.3.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Export the BorgSTMF object to JavaScript global scope
|
// Export the BorgSTMF object to JavaScript global scope
|
||||||
|
|
@ -32,6 +32,7 @@ func main() {
|
||||||
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
|
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
|
||||||
"decrypt": js.FuncOf(smsgDecrypt),
|
"decrypt": js.FuncOf(smsgDecrypt),
|
||||||
"decryptStream": js.FuncOf(smsgDecryptStream),
|
"decryptStream": js.FuncOf(smsgDecryptStream),
|
||||||
|
"decryptV3": js.FuncOf(smsgDecryptV3), // v3 streaming with rolling keys
|
||||||
"encrypt": js.FuncOf(smsgEncrypt),
|
"encrypt": js.FuncOf(smsgEncrypt),
|
||||||
"encryptWithManifest": js.FuncOf(smsgEncryptWithManifest),
|
"encryptWithManifest": js.FuncOf(smsgEncryptWithManifest),
|
||||||
"getInfo": js.FuncOf(smsgGetInfo),
|
"getInfo": js.FuncOf(smsgGetInfo),
|
||||||
|
|
@ -495,6 +496,25 @@ func smsgGetInfo(this js.Value, args []js.Value) interface{} {
|
||||||
result["hint"] = header.Hint
|
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
|
// Include manifest if present
|
||||||
if header.Manifest != nil {
|
if header.Manifest != nil {
|
||||||
result["manifest"] = manifestToJS(header.Manifest)
|
result["manifest"] = manifestToJS(header.Manifest)
|
||||||
|
|
@ -626,6 +646,118 @@ func smsgQuickDecrypt(this js.Value, args []js.Value) interface{} {
|
||||||
return promiseConstructor.New(handler)
|
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
|
// messageToJS converts an smsg.Message to a JavaScript object
|
||||||
func messageToJS(msg *smsg.Message) js.Value {
|
func messageToJS(msg *smsg.Message) js.Value {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue