Secure Client Storage
The following information should be stored locally if possible:
- device private keys (signing and encryption)
- sessionKey (OPAQUE)
- unencrypted content (folder names, document titles, document names)
- chain events (workspace-chains, user-chains, document-chains)
Storage for content
General
Sqlite was chosen as the database to store the data locally. Depending on the environment the database is only stored in-memory and/or encrypted and persisted on the local file system.
- Web: only in-memory
- Electron: in-memory and regularliy and encrypted on the local file system
- iOS and Android: every change is directly encrypted and persisted on the local file system using sqlcipher
All code related to the SQLite database integration can be found in this folder https://github.com/serenity-kit/Serenity/tree/main/apps/app/store (opens in a new tab).
The following data is currently stored in there:
- user https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/userStore.ts#L14-L25 (opens in a new tab)
- currentUserInfo https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/currentUserInfoStore.ts (opens in a new tab)
- userChain https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/userChainStore.ts (opens in a new tab)
- workspace (only id & name) https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/workspaceStore.ts#L20-L21 (opens in a new tab)
- workspaceChain https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/workspaceChainStore.ts (opens in a new tab)
- workspaceMemberDevicesProof https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/workspaceMemberDevicesProofStore.ts (opens in a new tab)
- documentChain https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/documentChainStore.ts (opens in a new tab)
- document content https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/documentStore.ts (opens in a new tab)
- app state https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/appStateStore.ts (opens in a new tab) (lastOpenWorkspaceId for a better UX to open the last open workspace on app start)
The benefit of using a local sqlite DB as state management is, that it the same way in all environments and only need to switch the storage implementation. This simplify the application logic and ideally reduces bugs.
And since chain and workspaceMemberDevicesProof entries are never overwritten by new data coming from the server, the server is discourage to send broken entries since a client either can persist then or could be long-living if users keep their browser tabs open.
Web
For the web client the in-memory Sqlite DB is used. The database is created on app start and deleted on logout.
https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/sql/sql.web.ts#L23-L26 (opens in a new tab) (reset DB on logout)
Electron
For the Electron client the in-memory Sqlite DB is used.
The in-memory Sqlite db is regularliy exported (currently debounces on every document write), encrypted and stored on the local file system. The key and nonce to encrypt the database is encrypted using https://www.electronjs.org/docs/latest/api/safe-storage (opens in a new tab) which is also stored on the local file system.
key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
encryptedKeyAndNonce = electron.safeStorage.encryptString(serialize(key, nonce));
encryptedDatabase = sodium.crypto_secretbox_easy(database, nonce, key);
When the application is started the encrypted database is loaded and decrypted using the key and nonce. The decrypted database is then used as the in-memory database.
decryptedDataString = electron.safeStorage.decryptString(encryptedKeyAndNonce);
decryptedData = deserialize(decryptedDataString);
decryptedDatabase = sodium.crypto_secretbox_open_easy(data, nonce, key);
On logout the database, key-and-nonce file is deleted.
Considerations for alternative designs
An alternative design could be to use https://github.com/signalapp/better-sqlite3 (opens in a new tab). It is a SQLCipher implementation working in Electron. The API is async and won't work for a good UX. Therefor we would need to manage the writes between the in-memory and the persisted DB. This includes managing failed writes and possible recovery. This is out of scope for now, but we will consider it in the future.
Mobile
For the mobile devices (ReactNative) no in-memory Sqlite database is used, but instead a React Native wrapper library https://github.com/OP-Engineering/op-sqlcipher (opens in a new tab) to directly use https://github.com/sqlcipher/sqlcipher (opens in a new tab) to persist data encrypted on the device.
Sqlcipher requires and encryption key. The key is generated every time and no key is stored in the secure store of the device. The key is generated using sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES)
.
When the user logs out a the database is deleted and the key in the secure store is deleted. Right after that the ready
function runs again and creates a new database and key.
Database reset on logout
On logout the local in-memory DB must be wiped. For the in-memory DB it is fine to just reconnect and the new instance will be empty.
Storage for device and session keys
Environments
iOS and Android
The expo-secure-store
package is used to store a Device
using Keychain on iOS and KeyStore on Android. Both leverage a Secure Element to keep the data secure.
Code reference: https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/deviceStore/deviceStore.ts (opens in a new tab)
Also for the sessionKey the expo-secure-store
package is used to store the sessionKey in the Keychain on iOS and KeyStore on Android.
Desktop clients
To store the device with it's private keys we use Electron's https://www.electronjs.org/docs/latest/api/safe-storage (opens in a new tab). It integrates with the macOS Keychain and Windows Credential Manager on Windows.
Here the safeStorage.encryptString
and safeStorage.decryptString
is used to encrypt/decrypt a string with the serialized device. The encrypted string is stored on the local file system.
Encryption:
serializedDevice = JSON.stringify(device);
const encryptedDevice = await safeStorage.encryptString(serializedDevice);
Decryption:
const serializedDevice = await safeStorage.decryptString(encryptedDevice);
const device = JSON.parse(serializedDevice);
The session key is stored in the same way as the device.
Web client
Since in a web client no secure storage available and therefor no content is persistet on the client. Possibly hashes of the chains (e.g. workspace-chain) could be stored, but this is not implemented and no decision in favor of it has been made.
Also device private keys are not stored locally. The only data that is stored locally is the sessionKey, webDeviceAccessToken and a webDeviceKey. The webDeviceKey is used to encrypt the web device (incl. private signing and encryption key) and stored on the server when adding the device to the account.
When a user open the application the webDeviceAccessToken is used to fetch the encrypted device and decrypt it with the webDeviceKey. The decrypted device is then stored only in memory and will be gone after closing the browser tab.
https://github.com/serenity-kit/Serenity/blob/main/apps/app/hooks/useCachedResources.ts (opens in a new tab) (invoked on app start)
This design was chosen to ensure that a revoked or expired web device can't be extracted from a compromised browser since the server rejects returning the device ciphertext and nonce. The design that was rejected was to store the device private keys directly in the browser's LocalStorage. This would have been possible, but would have meant that a compromised browser could have extracted the device private keys.
Code reference for the webDeviceStore: https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/webDeviceStore.ts (opens in a new tab)
The session key in comparision is directly stored in localstorage using the @react-native-async-storage/async-storage
package: https://github.com/serenity-kit/Serenity/blob/main/apps/app/store/sessionKeyStore/sessionKeyStore.ts (opens in a new tab)
Deleting keys on logout
On logout the device and session keys must be deleted. This is done by calling the clearDeviceAndSessionStorage
function.