//go:build js && wasm // Package main provides the WASM entry point for Borg encryption. // This module exposes encryption/decryption functions to JavaScript for: // - STMF: Client-side form encryption using server's public key // - SMSG: Password-based secure message decryption package main import ( "encoding/base64" "encoding/json" "syscall/js" "github.com/Snider/Borg/pkg/smsg" "github.com/Snider/Borg/pkg/stmf" "github.com/Snider/Enchantrix/pkg/enchantrix" ) // Version of the WASM module const Version = "1.6.0" func main() { // Export the BorgSTMF object to JavaScript global scope js.Global().Set("BorgSTMF", js.ValueOf(map[string]interface{}{ "encrypt": js.FuncOf(encrypt), "encryptFields": js.FuncOf(encryptFields), "generateKeyPair": js.FuncOf(generateKeyPair), "version": Version, "ready": true, })) // Export BorgSMSG for secure message handling js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{ "decrypt": js.FuncOf(smsgDecrypt), "decryptStream": js.FuncOf(smsgDecryptStream), "decryptBinary": js.FuncOf(smsgDecryptBinary), // v2/v3 binary input (no base64!) "decryptV3": js.FuncOf(smsgDecryptV3), // v3 streaming with rolling keys "getV3ChunkInfo": js.FuncOf(smsgGetV3ChunkInfo), // Get chunk index for seeking "decryptV3Chunk": js.FuncOf(smsgDecryptV3Chunk), // Decrypt single chunk "unwrapV3CEK": js.FuncOf(smsgUnwrapV3CEK), // Unwrap CEK for chunk decryption "parseV3Header": js.FuncOf(smsgParseV3Header), // Parse header from bytes, returns header + payloadOffset "unwrapCEKFromHeader": js.FuncOf(smsgUnwrapCEKFromHeader), // Unwrap CEK from parsed header "decryptChunkDirect": js.FuncOf(smsgDecryptChunkDirect), // Decrypt raw chunk bytes with CEK "encrypt": js.FuncOf(smsgEncrypt), "encryptWithManifest": js.FuncOf(smsgEncryptWithManifest), "getInfo": js.FuncOf(smsgGetInfo), "getInfoBinary": js.FuncOf(smsgGetInfoBinary), // Binary input (no base64!) "quickDecrypt": js.FuncOf(smsgQuickDecrypt), // ABR (Adaptive Bitrate Streaming) functions "parseABRManifest": js.FuncOf(smsgParseABRManifest), // Parse ABR manifest JSON "selectVariant": js.FuncOf(smsgSelectVariant), // Select best variant for bandwidth "version": Version, "ready": true, })) // Dispatch a ready event dispatchReadyEvent() // Keep the WASM module alive select {} } // dispatchReadyEvent fires a custom event to notify JS that WASM is loaded func dispatchReadyEvent() { event := js.Global().Get("CustomEvent").New("borgstmf:ready", map[string]interface{}{ "detail": map[string]interface{}{ "version": Version, }, }) js.Global().Get("document").Call("dispatchEvent", event) } // encrypt encrypts form data using the server's public key. // JavaScript usage: // // const result = await BorgSTMF.encrypt(formDataJSON, serverPublicKeyBase64); // // result is a base64-encoded STMF payload func encrypt(this js.Value, args []js.Value) interface{} { // Return a Promise 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("encrypt requires 2 arguments: formDataJSON, serverPublicKeyBase64")) return } formDataJSON := args[0].String() serverPubKeyB64 := args[1].String() // Parse form data var formData stmf.FormData if err := json.Unmarshal([]byte(formDataJSON), &formData); err != nil { reject.Invoke(newError("invalid form data JSON: " + err.Error())) return } // Decode server public key serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64) if err != nil { reject.Invoke(newError("invalid server public key base64: " + err.Error())) return } // Encrypt encryptedB64, err := stmf.EncryptBase64(&formData, serverPubKey) if err != nil { reject.Invoke(newError("encryption failed: " + err.Error())) return } resolve.Invoke(encryptedB64) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // encryptFields encrypts a simple key-value object of form fields. // JavaScript usage: // // const result = await BorgSTMF.encryptFields({ // email: 'user@example.com', // password: 'secret' // }, serverPublicKeyBase64, metadata); func encryptFields(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("encryptFields requires at least 2 arguments: fields, serverPublicKeyBase64")) return } fieldsObj := args[0] serverPubKeyB64 := args[1].String() // Build FormData from JavaScript object formData := stmf.NewFormData() // Get field names keys := js.Global().Get("Object").Call("keys", fieldsObj) keysLen := keys.Length() for i := 0; i < keysLen; i++ { key := keys.Index(i).String() value := fieldsObj.Get(key) // Handle different value types if value.Type() == js.TypeString { formData.AddField(key, value.String()) } else if value.Type() == js.TypeObject { // Check if it's a file-like object if !value.Get("name").IsUndefined() && !value.Get("value").IsUndefined() { field := stmf.FormField{ Name: key, Value: value.Get("value").String(), } if !value.Get("type").IsUndefined() { field.Type = value.Get("type").String() } if !value.Get("filename").IsUndefined() { field.Filename = value.Get("filename").String() } if !value.Get("mime").IsUndefined() { field.MimeType = value.Get("mime").String() } formData.Fields = append(formData.Fields, field) } else { // Convert to JSON string jsonStr := js.Global().Get("JSON").Call("stringify", value).String() formData.AddField(key, jsonStr) } } else { // Convert to string formData.AddField(key, value.String()) } } // Handle optional metadata argument if len(args) >= 3 && args[2].Type() == js.TypeObject { metaObj := args[2] metaKeys := js.Global().Get("Object").Call("keys", metaObj) metaLen := metaKeys.Length() for i := 0; i < metaLen; i++ { key := metaKeys.Index(i).String() value := metaObj.Get(key).String() formData.SetMetadata(key, value) } } // Decode server public key serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64) if err != nil { reject.Invoke(newError("invalid server public key base64: " + err.Error())) return } // Encrypt encryptedB64, err := stmf.EncryptBase64(formData, serverPubKey) if err != nil { reject.Invoke(newError("encryption failed: " + err.Error())) return } resolve.Invoke(encryptedB64) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // generateKeyPair generates a new X25519 keypair for testing/development. // JavaScript usage: // // const keypair = await BorgSTMF.generateKeyPair(); // console.log(keypair.publicKey); // base64 public key // console.log(keypair.privateKey); // base64 private key (keep secret!) func generateKeyPair(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() { kp, err := stmf.GenerateKeyPair() if err != nil { reject.Invoke(newError("key generation failed: " + err.Error())) return } result := map[string]interface{}{ "publicKey": kp.PublicKeyBase64(), "privateKey": kp.PrivateKeyBase64(), } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // newError creates a JavaScript Error object func newError(message string) js.Value { return js.Global().Get("Error").New(message) } // smsgDecrypt decrypts a base64-encoded SMSG with a password. // JavaScript usage: // // const message = await BorgSMSG.decrypt(encryptedBase64, password); // console.log(message.body); // console.log(message.subject); // console.log(message.attachments); func smsgDecrypt(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("decrypt requires 2 arguments: encryptedBase64, password")) return } encryptedB64 := args[0].String() password := args[1].String() msg, err := smsg.DecryptBase64(encryptedB64, password) if err != nil { reject.Invoke(newError("decryption failed: " + err.Error())) return } // Convert message to JS object result := messageToJS(msg) resolve.Invoke(result) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgDecryptStream decrypts and returns attachment data as Uint8Array for streaming. // This is more efficient than the regular decrypt which returns base64 strings. // JavaScript usage: // // const result = await BorgSMSG.decryptStream(encryptedBase64, password); // // result.attachments[0].data is a Uint8Array ready for MediaSource/Blob // const blob = new Blob([result.attachments[0].data], {type: result.attachments[0].mime}); // audio.src = URL.createObjectURL(blob); func smsgDecryptStream(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("decryptStream requires 2 arguments: encryptedBase64, password")) return } encryptedB64 := args[0].String() password := args[1].String() msg, err := smsg.DecryptBase64(encryptedB64, password) if err != nil { reject.Invoke(newError("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 } // Convert attachments with binary data (not base64 string) 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, // Direct binary data! } } result["attachments"] = attachments } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgDecryptBinary decrypts v2/v3 binary data directly from Uint8Array. // No base64 conversion needed - this is the efficient path for zstd streams. // JavaScript usage: // // const response = await fetch(url); // const bytes = new Uint8Array(await response.arrayBuffer()); // const result = await BorgSMSG.decryptBinary(bytes, password); // const blob = new Blob([result.attachments[0].data], {type: result.attachments[0].mime}); func smsgDecryptBinary(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("decryptBinary requires 2 arguments: Uint8Array, password")) return } // Get binary data directly from Uint8Array uint8Array := args[0] length := uint8Array.Get("length").Int() data := make([]byte, length) js.CopyBytesToGo(data, uint8Array) password := args[1].String() // Decrypt directly from binary (no base64 decode!) msg, err := smsg.Decrypt(data, password) if err != nil { reject.Invoke(newError("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 } // 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 (internal format still uses base64) attData, err := base64.StdEncoding.DecodeString(att.Content) if err != nil { reject.Invoke(newError("failed to decode attachment: " + err.Error())) return } // Create Uint8Array in JS attArray := js.Global().Get("Uint8Array").New(len(attData)) js.CopyBytesToJS(attArray, attData) attachments[i] = map[string]interface{}{ "name": att.Name, "mime": att.MimeType, "size": len(attData), "data": attArray, } } result["attachments"] = attachments } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgGetInfoBinary extracts header info from binary Uint8Array without decrypting. // JavaScript usage: // // const bytes = new Uint8Array(await response.arrayBuffer()); // const info = await BorgSMSG.getInfoBinary(bytes); // console.log(info.manifest); func smsgGetInfoBinary(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) < 1 { reject.Invoke(newError("getInfoBinary requires 1 argument: Uint8Array")) return } // Get binary data directly from Uint8Array uint8Array := args[0] length := uint8Array.Get("length").Int() data := make([]byte, length) js.CopyBytesToGo(data, uint8Array) header, err := smsg.GetInfo(data) if err != nil { reject.Invoke(newError("failed to get info: " + err.Error())) return } result := map[string]interface{}{ "version": header.Version, "algorithm": header.Algorithm, } if header.Format != "" { result["format"] = header.Format } if header.Compression != "" { result["compression"] = header.Compression } if 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, } } result["wrappedKeys"] = wrappedKeys result["isV3Streaming"] = true } // V3 chunked streaming fields if header.Chunked != nil { index := make([]interface{}, len(header.Chunked.Index)) for i, ci := range header.Chunked.Index { index[i] = map[string]interface{}{ "offset": ci.Offset, "size": ci.Size, } } result["chunked"] = map[string]interface{}{ "chunkSize": header.Chunked.ChunkSize, "totalChunks": header.Chunked.TotalChunks, "totalSize": header.Chunked.TotalSize, "index": index, } result["isChunked"] = true } // Include manifest if present if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgEncrypt encrypts a message with a password. // JavaScript usage: // // const encrypted = await BorgSMSG.encrypt({ // body: 'Hello!', // subject: 'Test', // from: 'support@example.com' // }, password); func smsgEncrypt(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("encrypt requires 2 arguments: messageObject, password")) return } msgObj := args[0] password := args[1].String() // Build message from JS object msg := smsg.NewMessage(msgObj.Get("body").String()) if !msgObj.Get("subject").IsUndefined() { msg.WithSubject(msgObj.Get("subject").String()) } if !msgObj.Get("from").IsUndefined() { msg.WithFrom(msgObj.Get("from").String()) } // Handle attachments attachments := msgObj.Get("attachments") if !attachments.IsUndefined() && attachments.Length() > 0 { for i := 0; i < attachments.Length(); i++ { att := attachments.Index(i) name := att.Get("name").String() content := att.Get("content").String() mimeType := "" if !att.Get("mime").IsUndefined() { mimeType = att.Get("mime").String() } msg.AddAttachment(name, content, mimeType) } } // Handle reply key replyKey := msgObj.Get("replyKey") if !replyKey.IsUndefined() { msg.WithReplyKey(replyKey.Get("publicKey").String()) } // Handle metadata meta := msgObj.Get("meta") if !meta.IsUndefined() && meta.Type() == js.TypeObject { keys := js.Global().Get("Object").Call("keys", meta) for i := 0; i < keys.Length(); i++ { key := keys.Index(i).String() value := meta.Get(key).String() msg.SetMeta(key, value) } } // Get optional hint hint := "" if len(args) >= 3 && args[2].Type() == js.TypeString { hint = args[2].String() } var encrypted []byte var err error if hint != "" { encrypted, err = smsg.EncryptWithHint(msg, password, hint) } else { encrypted, err = smsg.Encrypt(msg, password) } if err != nil { reject.Invoke(newError("encryption failed: " + err.Error())) return } encryptedB64 := base64.StdEncoding.EncodeToString(encrypted) resolve.Invoke(encryptedB64) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgGetInfo extracts header info from an SMSG without decrypting. // JavaScript usage: // // const info = await BorgSMSG.getInfo(encryptedBase64); // console.log(info.hint); // password hint if set // console.log(info.version); // console.log(info.manifest); // public metadata (title, artist, tracks, etc.) func smsgGetInfo(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) < 1 { reject.Invoke(newError("getInfo requires 1 argument: encryptedBase64")) return } encryptedB64 := args[0].String() header, err := smsg.GetInfoBase64(encryptedB64) if err != nil { reject.Invoke(newError("failed to get info: " + err.Error())) return } result := map[string]interface{}{ "version": header.Version, "algorithm": header.Algorithm, } if header.Format != "" { result["format"] = header.Format } if header.Compression != "" { result["compression"] = header.Compression } if 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 } // V3 chunked streaming fields if header.Chunked != nil { index := make([]interface{}, len(header.Chunked.Index)) for i, ci := range header.Chunked.Index { index[i] = map[string]interface{}{ "offset": ci.Offset, "size": ci.Size, } } result["chunked"] = map[string]interface{}{ "chunkSize": header.Chunked.ChunkSize, "totalChunks": header.Chunked.TotalChunks, "totalSize": header.Chunked.TotalSize, "index": index, } result["isChunked"] = true } // Include manifest if present if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgEncryptWithManifest encrypts a message with public manifest metadata. // JavaScript usage: // // const encrypted = await BorgSMSG.encryptWithManifest({ // body: 'Licensed content', // attachments: [{name: 'track.mp3', content: '...', mime: 'audio/mpeg'}] // }, password, { // title: 'My Song', // artist: 'Artist Name', // tracks: [{title: 'Intro', start: 0}, {title: 'Drop', start: 60}] // }); func smsgEncryptWithManifest(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) < 3 { reject.Invoke(newError("encryptWithManifest requires 3 arguments: messageObject, password, manifestObject")) return } msgObj := args[0] password := args[1].String() manifestObj := args[2] // Build message from JS object msg := smsg.NewMessage(msgObj.Get("body").String()) if !msgObj.Get("subject").IsUndefined() { msg.WithSubject(msgObj.Get("subject").String()) } if !msgObj.Get("from").IsUndefined() { msg.WithFrom(msgObj.Get("from").String()) } // Handle attachments attachments := msgObj.Get("attachments") if !attachments.IsUndefined() && attachments.Length() > 0 { for i := 0; i < attachments.Length(); i++ { att := attachments.Index(i) name := att.Get("name").String() content := att.Get("content").String() mimeType := "" if !att.Get("mime").IsUndefined() { mimeType = att.Get("mime").String() } msg.AddAttachment(name, content, mimeType) } } // Handle metadata (encrypted, inside payload) meta := msgObj.Get("meta") if !meta.IsUndefined() && meta.Type() == js.TypeObject { keys := js.Global().Get("Object").Call("keys", meta) for i := 0; i < keys.Length(); i++ { key := keys.Index(i).String() value := meta.Get(key).String() msg.SetMeta(key, value) } } // Build manifest from JS object (public, in header) manifest := jsToManifest(manifestObj) encrypted, err := smsg.EncryptWithManifest(msg, password, manifest) if err != nil { reject.Invoke(newError("encryption failed: " + err.Error())) return } encryptedB64 := base64.StdEncoding.EncodeToString(encrypted) resolve.Invoke(encryptedB64) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgQuickDecrypt is a convenience function that just returns the body text. // JavaScript usage: // // const body = await BorgSMSG.quickDecrypt(encryptedBase64, password); func smsgQuickDecrypt(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("quickDecrypt requires 2 arguments: encryptedBase64, password")) return } encryptedB64 := args[0].String() password := args[1].String() body, err := smsg.QuickDecrypt(encryptedB64, password) if err != nil { reject.Invoke(newError("decryption failed: " + err.Error())) return } resolve.Invoke(body) }() return nil }) promiseConstructor := js.Global().Get("Promise") 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 { headerResult := map[string]interface{}{ "format": header.Format, "keyMethod": header.KeyMethod, } if header.Cadence != "" { headerResult["cadence"] = string(header.Cadence) } // Include chunked info if present if header.Chunked != nil { headerResult["isChunked"] = true headerResult["chunked"] = map[string]interface{}{ "chunkSize": header.Chunked.ChunkSize, "totalChunks": header.Chunked.TotalChunks, "totalSize": header.Chunked.TotalSize, } } result["header"] = headerResult 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{}{ "body": msg.Body, "timestamp": msg.Timestamp, } if msg.Subject != "" { result["subject"] = msg.Subject } if msg.From != "" { result["from"] = msg.From } // Convert attachments if len(msg.Attachments) > 0 { attachments := make([]interface{}, len(msg.Attachments)) for i, att := range msg.Attachments { attachments[i] = map[string]interface{}{ "name": att.Name, "content": att.Content, "mime": att.MimeType, "size": att.Size, } } result["attachments"] = attachments } // Convert reply key if msg.ReplyKey != nil { result["replyKey"] = map[string]interface{}{ "publicKey": msg.ReplyKey.PublicKey, "keyId": msg.ReplyKey.KeyID, "algorithm": msg.ReplyKey.Algorithm, "fingerprint": msg.ReplyKey.Fingerprint, } } // Convert metadata if len(msg.Meta) > 0 { meta := make(map[string]interface{}) for k, v := range msg.Meta { meta[k] = v } result["meta"] = meta } return js.ValueOf(result) } // manifestToJS converts an smsg.Manifest to a JavaScript object func manifestToJS(m *smsg.Manifest) map[string]interface{} { result := make(map[string]interface{}) if m.Title != "" { result["title"] = m.Title } if m.Artist != "" { result["artist"] = m.Artist } if m.Album != "" { result["album"] = m.Album } if m.Genre != "" { result["genre"] = m.Genre } if m.Year > 0 { result["year"] = m.Year } if m.ReleaseType != "" { result["releaseType"] = m.ReleaseType } if m.Duration > 0 { result["duration"] = m.Duration } if m.Format != "" { result["format"] = m.Format } // License expiration fields if m.ExpiresAt > 0 { result["expiresAt"] = m.ExpiresAt } if m.IssuedAt > 0 { result["issuedAt"] = m.IssuedAt } if m.LicenseType != "" { result["licenseType"] = m.LicenseType } // Computed fields for convenience result["isExpired"] = m.IsExpired() result["timeRemaining"] = m.TimeRemaining() // Convert tracks if len(m.Tracks) > 0 { tracks := make([]interface{}, len(m.Tracks)) for i, t := range m.Tracks { track := map[string]interface{}{ "title": t.Title, "start": t.Start, } if t.End > 0 { track["end"] = t.End } if t.Type != "" { track["type"] = t.Type } if t.TrackNum > 0 { track["trackNum"] = t.TrackNum } tracks[i] = track } result["tracks"] = tracks } // Convert tags if len(m.Tags) > 0 { tags := make([]interface{}, len(m.Tags)) for i, tag := range m.Tags { tags[i] = tag } result["tags"] = tags } // Convert links if len(m.Links) > 0 { links := make(map[string]interface{}) for k, v := range m.Links { links[k] = v } result["links"] = links } // Convert extra if len(m.Extra) > 0 { extra := make(map[string]interface{}) for k, v := range m.Extra { extra[k] = v } result["extra"] = extra } return result } // smsgGetV3ChunkInfo extracts chunk information from a v3 file for seeking. // JavaScript usage: // // const info = await BorgSMSG.getV3ChunkInfo(encryptedBase64); // console.log(info.chunked.totalChunks); // console.log(info.chunked.index); // [{offset, size}, ...] func smsgGetV3ChunkInfo(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) < 1 { reject.Invoke(newError("getV3ChunkInfo requires 1 argument: encryptedBase64")) return } encryptedB64 := args[0].String() // Decode base64 data, err := base64.StdEncoding.DecodeString(encryptedB64) if err != nil { reject.Invoke(newError("invalid base64: " + err.Error())) return } // Get v3 header header, err := smsg.GetV3Header(data) if err != nil { reject.Invoke(newError("failed to get v3 header: " + err.Error())) return } result := map[string]interface{}{ "format": header.Format, "keyMethod": header.KeyMethod, "cadence": string(header.Cadence), } // Include chunked info if present if header.Chunked != nil { index := make([]interface{}, len(header.Chunked.Index)) for i, ci := range header.Chunked.Index { index[i] = map[string]interface{}{ "offset": ci.Offset, "size": ci.Size, } } result["chunked"] = map[string]interface{}{ "chunkSize": header.Chunked.ChunkSize, "totalChunks": header.Chunked.TotalChunks, "totalSize": header.Chunked.TotalSize, "index": index, } result["isChunked"] = true } else { result["isChunked"] = false } // Include manifest if present if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgUnwrapV3CEK unwraps the Content Encryption Key for chunk-by-chunk decryption. // JavaScript usage: // // const cek = await BorgSMSG.unwrapV3CEK(encryptedBase64, {license, fingerprint}); // // cek is base64-encoded CEK for use with decryptV3Chunk func smsgUnwrapV3CEK(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("unwrapV3CEK 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")) 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 } // Get header header, err := smsg.GetV3Header(data) if err != nil { reject.Invoke(newError("failed to get v3 header: " + err.Error())) return } // Unwrap CEK cek, err := smsg.UnwrapCEKFromHeader(header, params) if err != nil { reject.Invoke(newError("failed to unwrap CEK: " + err.Error())) return } // Return CEK as base64 for use with decryptV3Chunk cekB64 := base64.StdEncoding.EncodeToString(cek) resolve.Invoke(cekB64) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgDecryptV3Chunk decrypts a single chunk by index. // JavaScript usage: // // const info = await BorgSMSG.getV3ChunkInfo(encryptedBase64); // const cek = await BorgSMSG.unwrapV3CEK(encryptedBase64, {license, fingerprint}); // for (let i = 0; i < info.chunked.totalChunks; i++) { // const chunk = await BorgSMSG.decryptV3Chunk(encryptedBase64, cek, i); // // chunk is Uint8Array of decrypted data // } func smsgDecryptV3Chunk(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) < 3 { reject.Invoke(newError("decryptV3Chunk requires 3 arguments: encryptedBase64, cekBase64, chunkIndex")) return } encryptedB64 := args[0].String() cekB64 := args[1].String() chunkIndex := args[2].Int() // Decode base64 data data, err := base64.StdEncoding.DecodeString(encryptedB64) if err != nil { reject.Invoke(newError("invalid base64: " + err.Error())) return } // Decode CEK cek, err := base64.StdEncoding.DecodeString(cekB64) if err != nil { reject.Invoke(newError("invalid CEK base64: " + err.Error())) return } // Get header for chunk info header, err := smsg.GetV3Header(data) if err != nil { reject.Invoke(newError("failed to get v3 header: " + err.Error())) return } if header.Chunked == nil { reject.Invoke(newError("not a chunked v3 file")) return } // Get payload payload, err := smsg.GetV3Payload(data) if err != nil { reject.Invoke(newError("failed to get payload: " + err.Error())) return } // Decrypt the chunk decrypted, err := smsg.DecryptV3Chunk(payload, cek, chunkIndex, header.Chunked) if err != nil { reject.Invoke(newError("failed to decrypt chunk: " + err.Error())) return } // Return as Uint8Array uint8Array := js.Global().Get("Uint8Array").New(len(decrypted)) js.CopyBytesToJS(uint8Array, decrypted) resolve.Invoke(uint8Array) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgParseV3Header parses header from file bytes, returns header info + payload offset. // This allows streaming: fetch header first, then fetch chunks as needed. // JavaScript usage: // // const headerInfo = await BorgSMSG.parseV3Header(fileBytes); // // headerInfo.payloadOffset = where encrypted chunks start // // headerInfo.chunked.index = [{offset, size}, ...] relative to payload // // STREAMING: This function uses GetV3HeaderFromPrefix which only needs // the first few KB of the file. Call it as soon as ~3KB arrives. func smsgParseV3Header(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) < 1 { reject.Invoke(newError("parseV3Header requires 1 argument: Uint8Array")) return } // Get binary data from Uint8Array uint8Array := args[0] length := uint8Array.Get("length").Int() data := make([]byte, length) js.CopyBytesToGo(data, uint8Array) // Parse header from prefix - works with partial data! header, payloadOffset, err := smsg.GetV3HeaderFromPrefix(data) if err != nil { reject.Invoke(newError("failed to parse header: " + err.Error())) return } result := map[string]interface{}{ "format": header.Format, "keyMethod": header.KeyMethod, "cadence": string(header.Cadence), "payloadOffset": payloadOffset, } // Include wrapped keys for CEK unwrapping 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, "wrapped": wk.Wrapped, } } result["wrappedKeys"] = wrappedKeys } // Include chunk info if header.Chunked != nil { index := make([]interface{}, len(header.Chunked.Index)) for i, ci := range header.Chunked.Index { index[i] = map[string]interface{}{ "offset": ci.Offset, "size": ci.Size, } } result["chunked"] = map[string]interface{}{ "chunkSize": header.Chunked.ChunkSize, "totalChunks": header.Chunked.TotalChunks, "totalSize": header.Chunked.TotalSize, "index": index, } } if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) } resolve.Invoke(js.ValueOf(result)) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgUnwrapCEKFromHeader unwraps CEK using wrapped keys from header. // JavaScript usage: // // const headerInfo = await BorgSMSG.parseV3Header(fileBytes); // const cek = await BorgSMSG.unwrapCEKFromHeader(headerInfo.wrappedKeys, {license, fingerprint}, headerInfo.cadence); func smsgUnwrapCEKFromHeader(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("unwrapCEKFromHeader requires 2-3 arguments: wrappedKeys, {license, fingerprint}, [cadence]")) return } wrappedKeysJS := args[0] paramsObj := args[1] // Get cadence (optional, defaults to daily) cadence := smsg.CadenceDaily if len(args) >= 3 && !args[2].IsUndefined() { cadence = smsg.Cadence(args[2].String()) } // 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")) return } // Convert JS wrapped keys to Go var wrappedKeys []smsg.WrappedKey for i := 0; i < wrappedKeysJS.Length(); i++ { wk := wrappedKeysJS.Index(i) wrappedKeys = append(wrappedKeys, smsg.WrappedKey{ Date: wk.Get("date").String(), Wrapped: wk.Get("wrapped").String(), }) } // Build header with just the wrapped keys header := &smsg.Header{ WrappedKeys: wrappedKeys, Cadence: cadence, } params := &smsg.StreamParams{ License: license, Fingerprint: fingerprint, Cadence: cadence, } // Unwrap CEK cek, err := smsg.UnwrapCEKFromHeader(header, params) if err != nil { reject.Invoke(newError("failed to unwrap CEK: " + err.Error())) return } // Return CEK as Uint8Array cekArray := js.Global().Get("Uint8Array").New(len(cek)) js.CopyBytesToJS(cekArray, cek) resolve.Invoke(cekArray) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // smsgDecryptChunkDirect decrypts raw chunk bytes with CEK. // JavaScript usage: // // const chunkBytes = fileBytes.subarray(payloadOffset + chunk.offset, payloadOffset + chunk.offset + chunk.size); // const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, cek); func smsgDecryptChunkDirect(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("decryptChunkDirect requires 2 arguments: chunkBytes (Uint8Array), cek (Uint8Array)")) return } // Get chunk bytes chunkArray := args[0] chunkLen := chunkArray.Get("length").Int() chunkData := make([]byte, chunkLen) js.CopyBytesToGo(chunkData, chunkArray) // Get CEK cekArray := args[1] cekLen := cekArray.Get("length").Int() cek := make([]byte, cekLen) js.CopyBytesToGo(cek, cekArray) // Create sigil and decrypt sigil, err := enchantrix.NewChaChaPolySigil(cek) if err != nil { reject.Invoke(newError("failed to create sigil: " + err.Error())) return } decrypted, err := sigil.Out(chunkData) if err != nil { reject.Invoke(newError("decryption failed: " + err.Error())) return } // Return as Uint8Array result := js.Global().Get("Uint8Array").New(len(decrypted)) js.CopyBytesToJS(result, decrypted) resolve.Invoke(result) }() return nil }) promiseConstructor := js.Global().Get("Promise") return promiseConstructor.New(handler) } // jsToManifest converts a JavaScript object to an smsg.Manifest func jsToManifest(obj js.Value) *smsg.Manifest { if obj.IsUndefined() || obj.IsNull() { return nil } manifest := &smsg.Manifest{ Links: make(map[string]string), Extra: make(map[string]string), } if !obj.Get("title").IsUndefined() { manifest.Title = obj.Get("title").String() } if !obj.Get("artist").IsUndefined() { manifest.Artist = obj.Get("artist").String() } if !obj.Get("album").IsUndefined() { manifest.Album = obj.Get("album").String() } if !obj.Get("genre").IsUndefined() { manifest.Genre = obj.Get("genre").String() } if !obj.Get("year").IsUndefined() { manifest.Year = obj.Get("year").Int() } if !obj.Get("releaseType").IsUndefined() { manifest.ReleaseType = obj.Get("releaseType").String() } if !obj.Get("duration").IsUndefined() { manifest.Duration = obj.Get("duration").Int() } if !obj.Get("format").IsUndefined() { manifest.Format = obj.Get("format").String() } // License expiration fields if !obj.Get("expiresAt").IsUndefined() { manifest.ExpiresAt = int64(obj.Get("expiresAt").Float()) } if !obj.Get("issuedAt").IsUndefined() { manifest.IssuedAt = int64(obj.Get("issuedAt").Float()) } if !obj.Get("licenseType").IsUndefined() { manifest.LicenseType = obj.Get("licenseType").String() } // Parse tracks array tracks := obj.Get("tracks") if !tracks.IsUndefined() && tracks.Length() > 0 { for i := 0; i < tracks.Length(); i++ { t := tracks.Index(i) track := smsg.Track{ Title: t.Get("title").String(), Start: t.Get("start").Float(), TrackNum: i + 1, } if !t.Get("end").IsUndefined() { track.End = t.Get("end").Float() } if !t.Get("type").IsUndefined() { track.Type = t.Get("type").String() } if !t.Get("trackNum").IsUndefined() { track.TrackNum = t.Get("trackNum").Int() } manifest.Tracks = append(manifest.Tracks, track) } } // Parse tags array tags := obj.Get("tags") if !tags.IsUndefined() && tags.Length() > 0 { for i := 0; i < tags.Length(); i++ { manifest.Tags = append(manifest.Tags, tags.Index(i).String()) } } // Parse extra object extra := obj.Get("extra") if !extra.IsUndefined() && extra.Type() == js.TypeObject { keys := js.Global().Get("Object").Call("keys", extra) for i := 0; i < keys.Length(); i++ { key := keys.Index(i).String() manifest.Extra[key] = extra.Get(key).String() } } return manifest } // ========== ABR (Adaptive Bitrate Streaming) Functions ========== // smsgParseABRManifest parses an ABR manifest from JSON string. // JavaScript usage: // // const manifest = await BorgSMSG.parseABRManifest(jsonString); // // Returns: {version, title, duration, variants: [{name, bandwidth, width, height, url, ...}], defaultIdx} func smsgParseABRManifest(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) < 1 { reject.Invoke(newError("parseABRManifest requires 1 argument: jsonString")) return } jsonStr := args[0].String() manifest, err := smsg.ParseABRManifest([]byte(jsonStr)) if err != nil { reject.Invoke(newError("failed to parse ABR manifest: " + err.Error())) return } // Convert to JS object variants := make([]interface{}, len(manifest.Variants)) for i, v := range manifest.Variants { variants[i] = map[string]interface{}{ "name": v.Name, "bandwidth": v.Bandwidth, "width": v.Width, "height": v.Height, "codecs": v.Codecs, "url": v.URL, "chunkCount": v.ChunkCount, "fileSize": v.FileSize, } } result := map[string]interface{}{ "version": manifest.Version, "title": manifest.Title, "duration": manifest.Duration, "variants": variants, "defaultIdx": manifest.DefaultIdx, } resolve.Invoke(js.ValueOf(result)) }() return nil }) return js.Global().Get("Promise").New(handler) } // smsgSelectVariant selects the best variant for the given bandwidth. // JavaScript usage: // // const idx = await BorgSMSG.selectVariant(manifest, bandwidthBPS); // // Returns: index of best variant that fits within 80% of bandwidth func smsgSelectVariant(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("selectVariant requires 2 arguments: manifest, bandwidthBPS")) return } manifestObj := args[0] bandwidthBPS := args[1].Int() // Extract variants from JS object variantsJS := manifestObj.Get("variants") if variantsJS.IsUndefined() || variantsJS.Length() == 0 { reject.Invoke(newError("manifest has no variants")) return } // Build manifest struct manifest := &smsg.ABRManifest{ Variants: make([]smsg.Variant, variantsJS.Length()), } for i := 0; i < variantsJS.Length(); i++ { v := variantsJS.Index(i) manifest.Variants[i] = smsg.Variant{ Bandwidth: v.Get("bandwidth").Int(), } } // Select best variant selectedIdx := manifest.SelectVariant(bandwidthBPS) resolve.Invoke(selectedIdx) }() return nil }) return js.Global().Get("Promise").New(handler) }