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?)")
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue