diff --git a/indexedDb/implementation.go b/indexedDb/implementation.go index 410358d773fb486d31896dfe18fda0f8a29600b1..d9d2f7026d246f95a5e45def3863083805ae9434 100644 --- a/indexedDb/implementation.go +++ b/indexedDb/implementation.go @@ -39,6 +39,7 @@ const dbTimeout = time.Second // channel. type wasmModel struct { db *idb.Database + cipher cryptoChannel.Cipher receivedMessageCB MessageReceivedCallback updateMux sync.Mutex } @@ -206,6 +207,16 @@ func (w *wasmModel) ReceiveMessage(channelID *id.ID, timestamp time.Time, lease time.Duration, round rounds.Round, mType channels.MessageType, status channels.SentStatus) uint64 { + // Handle encryption, if it is present + if w.cipher != nil { + cipherText, err := w.cipher.Encrypt([]byte(text)) + if err != nil { + jww.ERROR.Printf("Failed to encrypt Message: %+v", err) + return 0 + } + text = string(cipherText) + } + msgToInsert := buildMessage( channelID.Marshal(), messageID.Bytes(), nil, nickname, text, pubKey, codeset, timestamp, lease, round.ID, mType, status) diff --git a/indexedDb/implementation_test.go b/indexedDb/implementation_test.go index 68a8dfeb6f04a8a1e1f9c141d9e07fa14f45019f..34a1d4fc5754f106fff55ca755637850c171a6f3 100644 --- a/indexedDb/implementation_test.go +++ b/indexedDb/implementation_test.go @@ -12,6 +12,7 @@ package indexedDb import ( "encoding/json" "fmt" + "gitlab.com/elixxir/xxdk-wasm/storage" "gitlab.com/xx_network/primitives/netTime" "os" "strconv" @@ -37,7 +38,7 @@ func dummyCallback(uint64, *id.ID, bool) {} func Test_wasmModel_UpdateSentStatus(t *testing.T) { testString := "test" testMsgId := channel.MakeMessageID([]byte(testString), &id.ID{1}) - eventModel, err := newWASMModel(testString, dummyCallback) + eventModel, err := newWASMModel(testString, nil, dummyCallback) if err != nil { t.Fatalf("%+v", err) } @@ -90,7 +91,8 @@ func Test_wasmModel_UpdateSentStatus(t *testing.T) { // Smoke test wasmModel.JoinChannel/wasmModel.LeaveChannel happy paths. func Test_wasmModel_JoinChannel_LeaveChannel(t *testing.T) { - eventModel, err := newWASMModel("test", dummyCallback) + storage.GetLocalStorage().Clear() + eventModel, err := newWASMModel("test", nil, dummyCallback) if err != nil { t.Fatalf("%+v", err) } @@ -129,7 +131,7 @@ func Test_wasmModel_JoinChannel_LeaveChannel(t *testing.T) { // Test UUID gets returned when different messages are added. func Test_wasmModel_UUIDTest(t *testing.T) { testString := "testHello" - eventModel, err := newWASMModel(testString, dummyCallback) + eventModel, err := newWASMModel(testString, nil, dummyCallback) if err != nil { t.Fatalf("%+v", err) } @@ -162,8 +164,9 @@ func Test_wasmModel_UUIDTest(t *testing.T) { // Tests if the same message ID being sent always returns the same UUID. func Test_wasmModel_DuplicateReceives(t *testing.T) { + storage.GetLocalStorage().Clear() testString := "testHello" - eventModel, err := newWASMModel(testString, dummyCallback) + eventModel, err := newWASMModel(testString, nil, dummyCallback) if err != nil { t.Fatalf("%+v", err) } @@ -200,7 +203,7 @@ func Test_wasmModel_deleteMsgByChannel(t *testing.T) { testString := "test_deleteMsgByChannel" totalMessages := 10 expectedMessages := 5 - eventModel, err := newWASMModel(testString, dummyCallback) + eventModel, err := newWASMModel(testString, nil, dummyCallback) if err != nil { t.Fatalf("%+v", err) } diff --git a/indexedDb/init.go b/indexedDb/init.go index 632ad0ee736ceb95327212f8af199a5df37c112d..95ae98a0ae3ce85da85e559dd59a4a8775f1d284 100644 --- a/indexedDb/init.go +++ b/indexedDb/init.go @@ -10,6 +10,9 @@ package indexedDb import ( + "github.com/pkg/errors" + cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/xxdk-wasm/storage" "syscall/js" "github.com/hack-pad/go-indexeddb/idb" @@ -37,25 +40,25 @@ type MessageReceivedCallback func(uuid uint64, channelID *id.ID, update bool) // NewWASMEventModelBuilder returns an EventModelBuilder which allows // the channel manager to define the path but the callback is the same // across the board. -func NewWASMEventModelBuilder( +func NewWASMEventModelBuilder(encryption cryptoChannel.Cipher, cb MessageReceivedCallback) channels.EventModelBuilder { fn := func(path string) (channels.EventModel, error) { - return NewWASMEventModel(path, cb) + return NewWASMEventModel(path, encryption, cb) } return fn } // NewWASMEventModel returns a [channels.EventModel] backed by a wasmModel. // The name should be a base64 encoding of the users public key. -func NewWASMEventModel(path string, cb MessageReceivedCallback) ( - channels.EventModel, error) { +func NewWASMEventModel(path string, encryption cryptoChannel.Cipher, + cb MessageReceivedCallback) (channels.EventModel, error) { databaseName := path + databaseSuffix - return newWASMModel(databaseName, cb) + return newWASMModel(databaseName, encryption, cb) } // newWASMModel creates the given [idb.Database] and returns a wasmModel. -func newWASMModel(databaseName string, cb MessageReceivedCallback) ( - *wasmModel, error) { +func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, + cb MessageReceivedCallback) (*wasmModel, error) { // Attempt to open database object ctx, cancel := newContext() defer cancel() @@ -112,7 +115,20 @@ func newWASMModel(databaseName string, cb MessageReceivedCallback) ( return nil, err } - return &wasmModel{db: db, receivedMessageCB: cb}, err + encryptionStatus := encryption != nil + loadedEncryptionStatus, err := storage.StoreIndexedDbEncryptionStatus( + databaseName, encryptionStatus) + if err != nil { + return nil, err + } + + if encryptionStatus != loadedEncryptionStatus { + return nil, errors.New( + "Cannot load database with different encryption status.") + } else if !encryptionStatus { + jww.WARN.Printf("IndexedDb encryption disabled!") + } + return &wasmModel{db: db, receivedMessageCB: cb, cipher: encryption}, err } // v1Upgrade performs the v0 -> v1 database upgrade. diff --git a/main.go b/main.go index 1214a9e82d1791ed11efabc5daf11829cf3f528f..fbec3d118fd1cff67c6a825f16c39e1f1636645b 100644 --- a/main.go +++ b/main.go @@ -70,6 +70,10 @@ func main() { js.FuncOf(wasm.NewChannelsManagerWithIndexedDb)) js.Global().Set("LoadChannelsManagerWithIndexedDb", js.FuncOf(wasm.LoadChannelsManagerWithIndexedDb)) + js.Global().Set("LoadChannelsManagerWithIndexedDbUnsafe", + js.FuncOf(wasm.LoadChannelsManagerWithIndexedDbUnsafe)) + js.Global().Set("NewChannelsManagerWithIndexedDbUnsafe", + js.FuncOf(wasm.NewChannelsManagerWithIndexedDbUnsafe)) js.Global().Set("GenerateChannel", js.FuncOf(wasm.GenerateChannel)) js.Global().Set("DecodePublicURL", js.FuncOf(wasm.DecodePublicURL)) js.Global().Set("DecodePrivateURL", js.FuncOf(wasm.DecodePrivateURL)) @@ -77,6 +81,8 @@ func main() { js.Global().Set("GetChannelInfo", js.FuncOf(wasm.GetChannelInfo)) js.Global().Set("GetShareUrlType", js.FuncOf(wasm.GetShareUrlType)) js.Global().Set("IsNicknameValid", js.FuncOf(wasm.IsNicknameValid)) + js.Global().Set("NewChannelsDatabaseCipher", + js.FuncOf(wasm.NewChannelsDatabaseCipher)) // wasm/cmix.go js.Global().Set("NewCmix", js.FuncOf(wasm.NewCmix)) diff --git a/storage/indexedDbEncryptionTrack.go b/storage/indexedDbEncryptionTrack.go new file mode 100644 index 0000000000000000000000000000000000000000..6e52a2648d3ea076578e9697fa3e9b591edf043f --- /dev/null +++ b/storage/indexedDbEncryptionTrack.go @@ -0,0 +1,37 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package storage + +import ( + "github.com/pkg/errors" + "os" +) + +// Key to store if the database is encrypted or not +const databaseEncryptionToggleKey = "xxdkWasmDatabaseEncryptionToggle/" + +// StoreIndexedDbEncryptionStatus stores the encryption status if it has not +// been previously saved. If it has, it returns its value. +func StoreIndexedDbEncryptionStatus( + databaseName string, encryption bool) (bool, error) { + data, err := GetLocalStorage().GetItem( + databaseEncryptionToggleKey + databaseName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + GetLocalStorage().SetItem( + databaseEncryptionToggleKey+databaseName, []byte{1}) + return encryption, nil + } else { + return false, err + } + } + + return data[0] == 1, nil +} diff --git a/storage/version.go b/storage/version.go index 263ad6a9c4c17762d84b6229f50b5c371fea2e65..e667145c9260470e6d367ddb75f952f17b714363 100644 --- a/storage/version.go +++ b/storage/version.go @@ -17,7 +17,7 @@ import ( ) // SEMVER is the current semantic version of xxDK WASM. -const SEMVER = "0.0.0" +const SEMVER = "0.1.0" // Storage keys. const ( diff --git a/wasm/channels.go b/wasm/channels.go index e84ac943a47eb8697aeaa10ea3905699fe65179e..e884ed233c4e282e23c678d1c4cc5653605d6aea 100644 --- a/wasm/channels.go +++ b/wasm/channels.go @@ -325,21 +325,72 @@ func LoadChannelsManager(_ js.Value, args []js.Value) interface{} { // returned as an int and the channelID as a Uint8Array. The row in the // database that was updated can be found using the UUID. The channel ID is // provided so that the recipient can filter if they want to the processes +// the update now or not. An "update" bool is present which tells you if the +// row is new or if it is an edited old row. +// - args[3] - ID of [ChannelDbCipher] object in tracker (int). Create this +// object with [NewChannelsDatabaseCipher] and get its id with +// [ChannelDbCipher.GetID]. +// +// Returns a promise: +// - Resolves to a Javascript representation of the [ChannelsManager] object. +// - Rejected with an error if loading indexedDb or the manager fails. +// - Throws a TypeError if the cipher ID does not correspond to a cipher. +func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) interface{} { + cmixID := args[0].Int() + privateIdentity := utils.CopyBytesToGo(args[1]) + cipherID := args[3].Int() + + cipher, err := bindings.GetChannelDbCipherTrackerFromID(cipherID) + if err != nil { + utils.Throw(utils.TypeError, err) + } + + return newChannelsManagerWithIndexedDb(cmixID, privateIdentity, args[2], cipher) +} + +// NewChannelsManagerWithIndexedDbUnsafe creates a new [ChannelsManager] from a +// new private identity ([channel.PrivateIdentity]) and using indexedDb as a +// backend to manage the event model. However, the data is written in plain text +// and not encrypted. It is recommended that you do not use this in production. +// +// This is for creating a manager for an identity for the first time. For +// generating a new one channel identity, use [GenerateChannelIdentity]. To +// reload this channel manager, use [LoadChannelsManagerWithIndexedDbUnsafe], +// passing in the storage tag retrieved by [ChannelsManager.GetStorageTag]. +// +// This function initialises an indexedDb database. +// +// Parameters: +// - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved +// using [Cmix.GetID]. +// - args[1] - Bytes of a private identity ([channel.PrivateIdentity]) that is +// generated by [GenerateChannelIdentity] (Uint8Array). +// - args[2] - Function that takes in the same parameters as +// [indexedDb.MessageReceivedCallback]. On the Javascript side, the UUID is +// returned as an int and the channelID as a Uint8Array. The row in the +// database that was updated can be found using the UUID. The channel ID is +// provided so that the recipient can filter if they want to the processes // the update now or not. An "update" bool is present which tells you if // the row is new or if it is an edited old row // // Returns a promise: // - Resolves to a Javascript representation of the [ChannelsManager] object. // - Rejected with an error if loading indexedDb or the manager fails. -func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) interface{} { +func NewChannelsManagerWithIndexedDbUnsafe(_ js.Value, args []js.Value) interface{} { cmixID := args[0].Int() privateIdentity := utils.CopyBytesToGo(args[1]) + return newChannelsManagerWithIndexedDb(cmixID, privateIdentity, args[2], nil) +} + +func newChannelsManagerWithIndexedDb(cmixID int, privateIdentity []byte, + cb js.Value, cipher *bindings.ChannelDbCipher) interface{} { + fn := func(uuid uint64, channelID *id.ID, update bool) { - args[2].Invoke(uuid, utils.CopyBytesToJS(channelID.Marshal()), update) + cb.Invoke(uuid, utils.CopyBytesToJS(channelID.Marshal()), update) } - model := indexedDb.NewWASMEventModelBuilder(fn) + model := indexedDb.NewWASMEventModelBuilder(cipher, fn) promiseFn := func(resolve, reject func(args ...interface{}) js.Value) { cm, err := bindings.NewChannelsManagerGoEventModel( @@ -372,21 +423,69 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) interface{} { // returned as an int and the channelID as a Uint8Array. The row in the // database that was updated can be found using the UUID. The channel ID is // provided so that the recipient can filter if they want to the processes +// the update now or not. An "update" bool is present which tells you if the +// row is new or if it is an edited old row. +// - args[3] - ID of [ChannelDbCipher] object in tracker (int). Create this +// object with [NewChannelsDatabaseCipher] and get its id with +// [ChannelDbCipher.GetID]. +// +// Returns a promise: +// - Resolves to a Javascript representation of the [ChannelsManager] object. +// - Rejected with an error if loading indexedDb or the manager fails. +// - Throws a TypeError if the cipher ID does not correspond to a cipher. +func LoadChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) interface{} { + cmixID := args[0].Int() + storageTag := args[1].String() + cipherID := args[3].Int() + + cipher, err := bindings.GetChannelDbCipherTrackerFromID(cipherID) + if err != nil { + utils.Throw(utils.TypeError, err) + } + + return loadChannelsManagerWithIndexedDb(cmixID, storageTag, args[2], cipher) +} + +// LoadChannelsManagerWithIndexedDbUnsafe loads an existing [ChannelsManager] +// using an existing indexedDb database as a backend to manage the event model. +// This should only be used to load unsafe channel managers created by +// [NewChannelsManagerWithIndexedDbUnsafe]. +// +// This is for loading a manager for an identity that has already been created. +// The channel manager should have previously been created with +// [NewChannelsManagerWithIndexedDb] and the storage is retrievable with +// [ChannelsManager.GetStorageTag]. +// +// Parameters: +// - args[0] - ID of [Cmix] object in tracker (int). This can be retrieved +// using [Cmix.GetID]. +// - args[1] - The storage tag associated with the previously created channel +// manager and retrieved with [ChannelsManager.GetStorageTag] (string). +// - args[2] - Function that takes in the same parameters as +// [indexedDb.MessageReceivedCallback]. On the Javascript side, the UUID is +// returned as an int and the channelID as a Uint8Array. The row in the +// database that was updated can be found using the UUID. The channel ID is +// provided so that the recipient can filter if they want to the processes // the update now or not. An "update" bool is present which tells you if // the row is new or if it is an edited old row // // Returns a promise: // - Resolves to a Javascript representation of the [ChannelsManager] object. // - Rejected with an error if loading indexedDb or the manager fails. -func LoadChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) interface{} { +func LoadChannelsManagerWithIndexedDbUnsafe(_ js.Value, args []js.Value) interface{} { cmixID := args[0].Int() storageTag := args[1].String() + return loadChannelsManagerWithIndexedDb(cmixID, storageTag, args[2], nil) +} + +func loadChannelsManagerWithIndexedDb(cmixID int, storageTag string, + cb js.Value, cipher *bindings.ChannelDbCipher) interface{} { fn := func(uuid uint64, channelID *id.ID, updated bool) { - args[2].Invoke(uuid, utils.CopyBytesToJS(channelID.Marshal()), updated) + cb.Invoke(uuid, utils.CopyBytesToJS(channelID.Marshal()), updated) } - model := indexedDb.NewWASMEventModelBuilder(fn) + model := indexedDb.NewWASMEventModelBuilder(cipher, fn) promiseFn := func(resolve, reject func(args ...interface{}) js.Value) { cm, err := bindings.LoadChannelsManagerGoEventModel( @@ -1274,3 +1373,108 @@ func (em *eventModel) UpdateSentStatus( em.updateSentStatus( uuid, utils.CopyBytesToJS(messageID), timestamp, roundID, status) } + +//////////////////////////////////////////////////////////////////////////////// +// Channel Cipher // +//////////////////////////////////////////////////////////////////////////////// + +// ChannelDbCipher wraps the [bindings.ChannelDbCipher] object so its methods +// can be wrapped to be Javascript compatible. +type ChannelDbCipher struct { + api *bindings.ChannelDbCipher +} + +// newChannelDbCipherJS creates a new Javascript compatible object +// (map[string]interface{}) that matches the [ChannelDbCipher] structure. +func newChannelDbCipherJS(api *bindings.ChannelDbCipher) map[string]interface{} { + c := ChannelDbCipher{api} + channelDbCipherMap := map[string]interface{}{ + "GetID": js.FuncOf(c.GetID), + "Encrypt": js.FuncOf(c.Encrypt), + "Decrypt": js.FuncOf(c.Decrypt), + } + + return channelDbCipherMap +} + +// NewChannelsDatabaseCipher constructs a [ChannelDbCipher] object. +// +// Parameters: +// - args[0] - The tracked [Cmix] object ID (int). +// - args[1] - The password for storage. This should be the same password +// passed into [NewCmix] (Uint8Array). +// - args[2] - The maximum size of a payload to be encrypted. A payload passed +// into [ChannelDbCipher.Encrypt] that is larger than this value will result +// in an error (int). +// +// Returns: +// - JavaScript representation of the [ChannelDbCipher] object. +// - Throws a TypeError if creating the cipher fails. +func NewChannelsDatabaseCipher(_ js.Value, args []js.Value) interface{} { + cmixId := args[0].Int() + password := utils.CopyBytesToGo(args[1]) + plaintTextBlockSize := args[2].Int() + + cipher, err := bindings.NewChannelsDatabaseCipher( + cmixId, password, plaintTextBlockSize) + if err != nil { + utils.Throw(utils.TypeError, err) + return nil + } + + return newChannelDbCipherJS(cipher) +} + +// GetID returns the ID for this [bindings.ChannelDbCipher] in the +// channelDbCipherTracker. +// +// Returns: +// - Tracker ID (int). +func (c *ChannelDbCipher) GetID(js.Value, []js.Value) interface{} { + return c.api.GetID() +} + +// Encrypt will encrypt the raw data. It will return a ciphertext. Padding is +// done on the plaintext so all encrypted data looks uniform at rest. +// +// Parameters: +// - args[0] - The data to be encrypted (Uint8Array). This must be smaller than +// the block size passed into [NewChannelsDatabaseCipher]. If it is larger, +// this will return an error. +// +// Returns: +// - The ciphertext of the plaintext passed in (Uint8Array). +// - Throws a TypeError if it fails to encrypt the plaintext. +func (c *ChannelDbCipher) Encrypt(_ js.Value, args []js.Value) interface{} { + + ciphertext, err := c.api.Encrypt(utils.CopyBytesToGo(args[0])) + if err != nil { + utils.Throw(utils.TypeError, err) + return nil + } + + return utils.CopyBytesToJS(ciphertext) + +} + +// Decrypt will decrypt the passed in encrypted value. The plaintext will be +// returned by this function. Any padding will be discarded within this +// function. +// +// Parameters: +// - args[0] - the encrypted data returned by [ChannelDbCipher.Encrypt] +// (Uint8Array). +// +// Returns: +// - The plaintext of the ciphertext passed in (Uint8Array). +// - Throws a TypeError if it fails to encrypt the plaintext. +func (c *ChannelDbCipher) Decrypt(_ js.Value, args []js.Value) interface{} { + plaintext, err := c.api.Decrypt(utils.CopyBytesToGo(args[0])) + if err != nil { + utils.Throw(utils.TypeError, err) + return nil + } + + return utils.CopyBytesToJS(plaintext) + +} diff --git a/wasm/channels_test.go b/wasm/channels_test.go index 2e19cffdb62ef95ca5c3d2b70d726d619ba66797..127657af1507d6389bdf94e7fb920f7e1dddb2de 100644 --- a/wasm/channels_test.go +++ b/wasm/channels_test.go @@ -60,6 +60,47 @@ func Test_ChannelsManagerMethods(t *testing.T) { } } +// Tests that the map representing ChannelDbCipher returned by +// newChannelDbCipherJS contains all of the methods on ChannelDbCipher. +func Test_newChannelDbCipherJS(t *testing.T) { + cipherType := reflect.TypeOf(&ChannelDbCipher{}) + + cipher := newChannelDbCipherJS(&bindings.ChannelDbCipher{}) + if len(cipher) != cipherType.NumMethod() { + t.Errorf("ChannelDbCipher JS object does not have all methods."+ + "\nexpected: %d\nreceived: %d", cipherType.NumMethod(), len(cipher)) + } + + for i := 0; i < cipherType.NumMethod(); i++ { + method := cipherType.Method(i) + + if _, exists := cipher[method.Name]; !exists { + t.Errorf("Method %s does not exist.", method.Name) + } + } +} + +// Tests that ChannelDbCipher has all the methods that +// [bindings.ChannelDbCipher] has. +func Test_ChannelDbCipherMethods(t *testing.T) { + cipherType := reflect.TypeOf(&ChannelDbCipher{}) + binCipherType := reflect.TypeOf(&bindings.ChannelDbCipher{}) + + if binCipherType.NumMethod() != cipherType.NumMethod() { + t.Errorf("WASM ChannelDbCipher object does not have all methods from "+ + "bindings.\nexpected: %d\nreceived: %d", + binCipherType.NumMethod(), cipherType.NumMethod()) + } + + for i := 0; i < binCipherType.NumMethod(); i++ { + method := binCipherType.Method(i) + + if _, exists := cipherType.MethodByName(method.Name); !exists { + t.Errorf("Method %s does not exist.", method.Name) + } + } +} + type jsIdentity struct { pubKey js.Value codeset js.Value diff --git a/wasm_test.go b/wasm_test.go index 1b25d22270e75984c19ee41beeaf0e9a455df33e..38d1bdd30e9cd3efe0569afe50903b94948761af 100644 --- a/wasm_test.go +++ b/wasm_test.go @@ -42,6 +42,7 @@ func TestPublicFunctions(t *testing.T) { "NewEventModel": {}, "NewChannelsManagerGoEventModel": {}, "LoadChannelsManagerGoEventModel": {}, + "GetChannelDbCipherTrackerFromID": {}, // Version functions were renamed to differentiate between WASM and // client versions