Profile page: - No WASM or video download until play button clicked - Play button visible immediately, loading on-demand - Removed auto-play behavior completely Streaming: - GetV3HeaderFromPrefix for parsing from partial data - v3 demo file with 128KB chunks for streaming tests
827 lines
24 KiB
Go
827 lines
24 KiB
Go
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)
|
|
ChunkSize int // Optional: chunk size for decrypt-while-downloading (0 = no chunking)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// When params.ChunkSize > 0, content is split into independently decryptable
|
|
// chunks, enabling decrypt-while-downloading and seeking.
|
|
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)
|
|
}
|
|
|
|
// Check if chunked mode requested
|
|
if params.ChunkSize > 0 {
|
|
return encryptV3Chunked(msg, params, manifest, cek, cadence, current, next, wrappedCurrent, wrappedNext)
|
|
}
|
|
|
|
// Non-chunked v3 (original behavior)
|
|
return encryptV3Standard(msg, params, manifest, cek, cadence, current, next, wrappedCurrent, wrappedNext)
|
|
}
|
|
|
|
// encryptV3Standard encrypts as a single block (original v3 behavior)
|
|
func encryptV3Standard(msg *Message, params *StreamParams, manifest *Manifest, cek []byte, cadence Cadence, current, next, wrappedCurrent, wrappedNext string) ([]byte, error) {
|
|
// 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)
|
|
}
|
|
|
|
// encryptV3Chunked encrypts content into independently decryptable chunks
|
|
func encryptV3Chunked(msg *Message, params *StreamParams, manifest *Manifest, cek []byte, cadence Cadence, current, next, wrappedCurrent, wrappedNext string) ([]byte, error) {
|
|
chunkSize := params.ChunkSize
|
|
|
|
// Build raw content to chunk: metadata JSON + binary attachments
|
|
metaJSON, attachmentData, err := buildV3Payload(msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Combine into single byte slice for chunking
|
|
rawContent := append(metaJSON, attachmentData...)
|
|
totalSize := int64(len(rawContent))
|
|
|
|
// Create sigil with CEK for chunk encryption
|
|
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
|
}
|
|
|
|
// Encrypt in chunks
|
|
var chunks [][]byte
|
|
var chunkIndex []ChunkInfo
|
|
offset := 0
|
|
|
|
for i := 0; offset < len(rawContent); i++ {
|
|
// Determine this chunk's size
|
|
end := offset + chunkSize
|
|
if end > len(rawContent) {
|
|
end = len(rawContent)
|
|
}
|
|
chunkData := rawContent[offset:end]
|
|
|
|
// Encrypt chunk (each gets its own nonce)
|
|
encryptedChunk, err := sigil.In(chunkData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt chunk %d: %w", i, err)
|
|
}
|
|
|
|
chunks = append(chunks, encryptedChunk)
|
|
chunkIndex = append(chunkIndex, ChunkInfo{
|
|
Offset: 0, // Will be calculated after we know all sizes
|
|
Size: len(encryptedChunk),
|
|
})
|
|
|
|
offset = end
|
|
}
|
|
|
|
// Calculate chunk offsets
|
|
currentOffset := 0
|
|
for i := range chunkIndex {
|
|
chunkIndex[i].Offset = currentOffset
|
|
currentOffset += chunkIndex[i].Size
|
|
}
|
|
|
|
// Build header with chunked info
|
|
chunkedInfo := &ChunkedInfo{
|
|
ChunkSize: chunkSize,
|
|
TotalChunks: len(chunks),
|
|
TotalSize: totalSize,
|
|
Index: chunkIndex,
|
|
}
|
|
|
|
headerMap := map[string]interface{}{
|
|
"version": Version,
|
|
"algorithm": "chacha20poly1305",
|
|
"format": FormatV3,
|
|
"compression": CompressionNone, // No compression in chunked mode (per-chunk not supported yet)
|
|
"keyMethod": KeyMethodLTHNRolling,
|
|
"cadence": string(cadence),
|
|
"chunked": chunkedInfo,
|
|
"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
|
|
}
|
|
|
|
// Concatenate all encrypted chunks
|
|
var payload []byte
|
|
for _, chunk := range chunks {
|
|
payload = append(payload, chunk...)
|
|
}
|
|
|
|
// Wrap in trix container
|
|
t := &trix.Trix{
|
|
Header: headerMap,
|
|
Payload: payload,
|
|
}
|
|
|
|
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.
|
|
// Automatically handles both chunked and non-chunked v3 formats.
|
|
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
|
|
}
|
|
|
|
// Check if chunked format
|
|
if header.Chunked != nil {
|
|
return decryptV3Chunked(t.Payload, cek, &header)
|
|
}
|
|
|
|
// Non-chunked v3
|
|
return decryptV3Standard(t.Payload, cek, &header)
|
|
}
|
|
|
|
// decryptV3Standard handles non-chunked v3 decryption
|
|
func decryptV3Standard(payload []byte, cek []byte, header *Header) (*Message, *Header, error) {
|
|
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
|
|
}
|
|
|
|
// decryptV3Chunked handles chunked v3 decryption
|
|
func decryptV3Chunked(payload []byte, cek []byte, header *Header) (*Message, *Header, error) {
|
|
if header.Chunked == nil {
|
|
return nil, header, fmt.Errorf("v3 chunked format missing chunked info")
|
|
}
|
|
|
|
// Create sigil for decryption
|
|
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
|
if err != nil {
|
|
return nil, header, fmt.Errorf("failed to create sigil: %w", err)
|
|
}
|
|
|
|
// Decrypt all chunks
|
|
var decrypted []byte
|
|
|
|
for i, ci := range header.Chunked.Index {
|
|
if ci.Offset+ci.Size > len(payload) {
|
|
return nil, header, fmt.Errorf("chunk %d out of bounds", i)
|
|
}
|
|
|
|
chunkData := payload[ci.Offset : ci.Offset+ci.Size]
|
|
plaintext, err := sigil.Out(chunkData)
|
|
if err != nil {
|
|
return nil, header, fmt.Errorf("failed to decrypt chunk %d: %w", i, err)
|
|
}
|
|
|
|
decrypted = append(decrypted, plaintext...)
|
|
}
|
|
|
|
// Parse decrypted content (metadata JSON + attachments)
|
|
var msg Message
|
|
if err := json.Unmarshal(decrypted, &msg); err != nil {
|
|
// First part should be JSON, but may be mixed with binary
|
|
// Try to find JSON boundary
|
|
for i := 0; i < len(decrypted); i++ {
|
|
if decrypted[i] == '}' {
|
|
if err := json.Unmarshal(decrypted[:i+1], &msg); err == nil {
|
|
// Found valid JSON, rest is attachment data
|
|
if err := restoreV3Attachments(&msg, decrypted[i+1:]); err != nil {
|
|
return nil, header, err
|
|
}
|
|
return &msg, header, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, header, fmt.Errorf("failed to parse message: %w", 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
|
|
}
|
|
|
|
// =============================================================================
|
|
// V3 Chunked Streaming Helpers
|
|
// =============================================================================
|
|
//
|
|
// When StreamParams.ChunkSize > 0, v3 format uses independently decryptable
|
|
// chunks, enabling:
|
|
// - Decrypt-while-downloading: Play media as it arrives
|
|
// - HTTP Range requests: Fetch specific chunks by byte range
|
|
// - Seekable playback: Jump to any position without decrypting everything
|
|
//
|
|
// Each chunk is encrypted with the same CEK but has its own nonce,
|
|
// making it independently decryptable.
|
|
|
|
// DecryptV3Chunk decrypts a single chunk by index.
|
|
// This enables streaming playback and seeking without decrypting the entire file.
|
|
//
|
|
// Usage for streaming:
|
|
//
|
|
// header, _ := GetV3Header(data)
|
|
// cek, _ := UnwrapCEKFromHeader(header, params)
|
|
// payload, _ := GetV3Payload(data)
|
|
// for i := 0; i < header.Chunked.TotalChunks; i++ {
|
|
// chunk, _ := DecryptV3Chunk(payload, cek, i, header.Chunked)
|
|
// player.Write(chunk)
|
|
// }
|
|
func DecryptV3Chunk(payload []byte, cek []byte, chunkIndex int, chunked *ChunkedInfo) ([]byte, error) {
|
|
if chunked == nil {
|
|
return nil, fmt.Errorf("chunked info is nil")
|
|
}
|
|
if chunkIndex < 0 || chunkIndex >= len(chunked.Index) {
|
|
return nil, fmt.Errorf("chunk index %d out of range [0, %d)", chunkIndex, len(chunked.Index))
|
|
}
|
|
|
|
ci := chunked.Index[chunkIndex]
|
|
if ci.Offset+ci.Size > len(payload) {
|
|
return nil, fmt.Errorf("chunk %d data out of bounds", chunkIndex)
|
|
}
|
|
|
|
// Create sigil and decrypt
|
|
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
|
}
|
|
|
|
chunkData := payload[ci.Offset : ci.Offset+ci.Size]
|
|
return sigil.Out(chunkData)
|
|
}
|
|
|
|
// GetV3Header extracts the header from a v3 file without decrypting.
|
|
// Useful for getting chunk index for Range requests.
|
|
func GetV3Header(data []byte) (*Header, error) {
|
|
t, err := trix.Decode(data, Magic, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode container: %w", err)
|
|
}
|
|
|
|
headerJSON, err := json.Marshal(t.Header)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal header: %w", err)
|
|
}
|
|
|
|
var header Header
|
|
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
|
return nil, fmt.Errorf("failed to parse header: %w", err)
|
|
}
|
|
|
|
if header.Format != FormatV3 {
|
|
return nil, fmt.Errorf("not a v3 format: %s", header.Format)
|
|
}
|
|
|
|
return &header, nil
|
|
}
|
|
|
|
// UnwrapCEKFromHeader unwraps the CEK from a v3 header using stream params.
|
|
// Returns the CEK for use with DecryptV3Chunk.
|
|
func UnwrapCEKFromHeader(header *Header, params *StreamParams) ([]byte, error) {
|
|
if params == nil || params.License == "" {
|
|
return nil, ErrLicenseRequired
|
|
}
|
|
|
|
cadence := header.Cadence
|
|
if cadence == "" && params.Cadence != "" {
|
|
cadence = params.Cadence
|
|
}
|
|
if cadence == "" {
|
|
cadence = CadenceDaily
|
|
}
|
|
|
|
return tryUnwrapCEK(header.WrappedKeys, params, cadence)
|
|
}
|
|
|
|
// GetV3Payload extracts just the payload from a v3 file.
|
|
// Use with DecryptV3Chunk for individual chunk decryption.
|
|
func GetV3Payload(data []byte) ([]byte, error) {
|
|
t, err := trix.Decode(data, Magic, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode container: %w", err)
|
|
}
|
|
return t.Payload, nil
|
|
}
|
|
|
|
// GetV3HeaderFromPrefix parses the v3 header from just the file prefix.
|
|
// This enables streaming: parse header as soon as first few KB arrive.
|
|
// Returns header and payload offset (where encrypted chunks start).
|
|
//
|
|
// File format:
|
|
// - Bytes 0-3: Magic "SMSG"
|
|
// - Bytes 4-5: Version (2-byte little endian)
|
|
// - Bytes 6-8: Header length (3-byte big endian)
|
|
// - Bytes 9+: Header JSON
|
|
// - Payload starts at offset 9 + headerLen
|
|
func GetV3HeaderFromPrefix(data []byte) (*Header, int, error) {
|
|
// Need at least magic + version + header length indicator
|
|
if len(data) < 9 {
|
|
return nil, 0, fmt.Errorf("need at least 9 bytes, got %d", len(data))
|
|
}
|
|
|
|
// Check magic
|
|
if string(data[0:4]) != Magic {
|
|
return nil, 0, ErrInvalidMagic
|
|
}
|
|
|
|
// Parse header length (3 bytes big endian at offset 6-8)
|
|
headerLen := int(data[6])<<16 | int(data[7])<<8 | int(data[8])
|
|
if headerLen <= 0 || headerLen > 16*1024*1024 {
|
|
return nil, 0, fmt.Errorf("invalid header length: %d", headerLen)
|
|
}
|
|
|
|
// Calculate payload offset
|
|
payloadOffset := 9 + headerLen
|
|
|
|
// Check if we have enough data for the header
|
|
if len(data) < payloadOffset {
|
|
return nil, 0, fmt.Errorf("need %d bytes for header, got %d", payloadOffset, len(data))
|
|
}
|
|
|
|
// Parse header JSON
|
|
headerJSON := data[9:payloadOffset]
|
|
var header Header
|
|
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
|
return nil, 0, fmt.Errorf("failed to parse header JSON: %w", err)
|
|
}
|
|
|
|
if header.Format != FormatV3 {
|
|
return nil, 0, fmt.Errorf("not a v3 format: %s", header.Format)
|
|
}
|
|
|
|
return &header, payloadOffset, nil
|
|
}
|