First, I see no issues in using the db just as an object store of encrypted blobs. It has the obvious limitations, but that's all.
Second, it is possible to do client-side encryption in javascript. There are good libraries you can use, don't try to roll your own. I would recommend using openpgp.js so you would just be working with openpgp keys and encrypted files.
Third, if you want to do the encryption client-side I would suggest:
Login:
- The user provides their username (I think you are using an email for that) and a password.
- Their browser calculates HMAC("login-password", password) and HMAC("private-key", password) locally
- The browser sends username and the result of HMAC("login-password", password) to the server
- The server validates the username and the 'password' it has been given. All the usual rules apply (logs of login attempts, blocking/throttling, detecting logins from unusual locations, etc.)
- On success the server sends to the client their encrypted private key
- Client decrypts their private key with HMAC("private-key", password)
Registration
- Client creates their key locally. Provides to the server their username, HMAC("login-password", password), publicKey, Encryption(privateKey, HMAC("private-key", password))
- For a new system I would recommend using argon rather than PBKDF2, although it isn't that relevant
Element get:
- Client requests element from server
- Server validates the user is allowed to fetch the element and sends the encrypted file to the client
- Client decrypts with their private key (obtained in the last step of login)
Element create:
- User creates the content to upload
- User queries the server for the public keys of the people it should be encrypted (depending on the specifics of your system, the user may be choosing specific people or have only a generic knowledge of the kind of people that would need access)
- The user encrypts the file locally with the public keys that it was given and sends to server
- The encrypted file is stored in the server along the list of people allowed to view it
Some notes:
All encryption and decryption happen client side. Your server never sees any plaintext content or private keys. This is the best standard one can achieve.
Even if the full database was stolen, an attacker wouldn't be able obtain the stored contents assuming the user passwords were good enough. Also note you could use separate databases / servers for storing the user accounts and the actual contents.
Weak points:
The server is providing the people and keys the data must be encrypted to. Thus, an attacker with write access, could add their own key to the list of keys that should be given access to certain content. You can alleviate this somewhat by listing all the people to which it will be encrypted prior to doing it. If the user knows to whom it should be encrypted (e.g. this is a system in which the users explicitly choose with whom to share the data), that would help. However, the attacker could replace the key of one of the recipients with their own key and, while you could show the key fingerprint in the interface (so that it can be detected), it would be very rare for people to actually verify them.
Related to this, you should think how should the data be shared. Suppose we would have patients sharing data with their doctors. Should they only encrypt the data to their doctor? What should happen if he needs to be treated by a different doctor? Should they be encrypted to a whole department? How do you plan to handle people joining and leaving?
Since this is a web application, the client side protection is good as long as the server keeps being honest. If an attacker replaced the client application code, for anyone which accesses the system after that, it could silently add himself as recipient, decrypt and leak the data or, simply, send the attacker a copy of the username and password to log in.
Metadata:
So far, I only covered the data itself. Beware of the metadata. Someone sharing a lot of files with an oncologist is likely to have cancer (now or in the past, which could be guessed from their timestamps). A folder full of tests named as a disease, is likely to be suffered by the patient, and so on.
UPDATE: to address the questions in the comments:
not all the user's data will be encrypted, only the health related data
No problem. You just define your application so that certain fields are encrypted while others aren't. Depending on how you define them you might have an additional concern of an attacker perhaps being able to reconfigure your application to convert encrypted fields into plaintext ones. Additionally, there's a documentation / UI question on how to design them so that users are not confused on what is stored securely and what isn't.
I read that we can't trust Javascript to do crypto operations
This is false. See the Web Cryptography API. This was true some decades ago, but no longer true. You should be careful with this, though. One thing you particularly don't want to do is to try to generate your own random values from weak-entropy sources (such as time values). Use getRandomValues()
and we can't trust the client at all to do it because all the encryptions operations would be visible in source code.
Viewing the operations is not insecure at all. Per Kerckhoffs's principle the security of a cryptosystem should be secure even if everything about the system, except the key, is public knowledge. If you use AES properly, there is no concern in an attacker knowing that you are using, say AES-GCM. Embedding the key in the source code would be a terrible idea, but it's not what we are proposing here.
So why I should doing that?
You came here asking for ways to improve the system you thought in.
I want to have the benefits of AES for encrypting and the agility of RSA for the permissions so I think I will stick with that.
I don't see where you wanted RSA for permissions. Anyway, what I described above works with AES+RSA (as well as any other hybrid system, the actual algorithms aren't relevant, as long as they are secure)
how can I store the encryption/private key securely in browser? I don't know what to choose between a secure/httpOnly cookie with that key in plain text or localStorage or indexDB.
A httpOnly cookie would be sent to your application. 20 years ago, that was all you had available, but nowadays you would use a different method. Still, you are risking to permanently lose access to the files if (when) the user changes computers/browsers or simply clears their stored data. That's why I stored it encrypted on the server with a user-chosen password (and then derived two secrets from the original password, to keep that transparent).
Finally, do you have an idea for the flaw where anyone could generate a new key for a resource, encrypt new unwanted data with it, and encrypt it with all public keys of currently authorized users to see the resource, replacing the original content. Is new layer of encryption needed?
No. You don't need encryption, you need permissions. Even with the encryption, your backend should check authorization, and not let users read nor write resources they should not have access to. If you are concerned about someone authorized overwriting the contents, you can store a history of the changes, so that it's possible to browse previous versions (although per GDPR you will need to support permanently deleting records).
You could also include cryptographic signatures on the fields to verify locally who wrote the record. Similar issues of trusting the matching between public keys and people apply, though.
New update:
I still don't understand where to store the decrypted private key using password for all the duration of the session. When we log-in, we have to store it in the client in order to not re-enter the password at each encryption or after page reload for example. So what should I use instead of a cookie in the browser and for the Flutter mobile app to send the decrypted private key each time after only one login in a secured way?
I'm not familiar with Flutter environment. Ideally, I would keep it in a variable and use a dynamic page with no real browsing to other pages. If that's not available for you, I would probably use something like sessionStorage
I have also a problem with HMAC("login-password", password). When we register, what should be stored? HMAC("login-password", password) or bcrypt(HMAC("login-password", password))?
From the point of view of your app, the user password is HMAC("login-password", password)
so you store a salted hash of that. Thus the bcrypt(HMAC("login-password", password)) option (although not necessarily with bcrypt, see e.g. my argon suggestion above)
"Depending on how you define them you might have an additional concern of an attacker perhaps being able to reconfigure your application to convert encrypted fields into plaintext ones." But even if I choose to encrypt all of the user's data, an attacker accessing the application could just remove the function call doing the encryption, what is the difference?
There is not a big difference. It depends on the layer where it is done. If you had a flag in the database saying which fields to encrypt, changing it may be simpler than modifying your the files with the code.
I was currently using npmjs.com/package/csprng for my tests, is this one also ok for random or should I change?
That package seems fine, according to its documentation.
And my main concern: with this system using only client-side encryption, as the API server, how can we check that: 1- the data that we want to be encrypted is actually and correctly encrypted since we don't do any encryption operation in the API side anymore?
You don't verify the encryption server-side. If the server could decrypt it in order to verify the contents, that would defeat the point of only the client being able to decrypt it. What you should do instead is to properly test that the client correctly encrypts the parameters that it should. It can also reject fields that should be encrypted but actually aren't correctly encrypted.
Note this doesn't preclude you from using a convention of e.g. placing a prefix on encrypted blobs. Your server could verify that fields that it is present for fields that are expected to be encrypted, in order to prevent unintentionally storing a non-encrypted version there.
2- the call to our API side is done from a trusted and validated client version that we developed and not another app using our register/login endpoints (I want to black that also but I don't know how to do it exactly since those endpoints are public by definition).
This is a completely different problem. And one that cannot be solved. You can have the validated client voluntarily provide their version to the server (so that it can e.g. warn about an update being available), up to using an embedded client certificate. Yet, no matter what you do, it can be reversed to use different client.
The good part is that it doesn't really matter which client is used. It doesn't affect the security of your system. A "bad" client could use weak random numbers, or even break the protocol by storing something else (and how is that different than a client that deleted the contents?), but that doesn't affect the security of the data stored using a proper client. If the security of the system relies in the (by definition) untrusted client being honest, then you're doing it wrong.