I'm developing an E2EE web chat app to learn more about encryption. I'm using WebCrypto API and hash-wasm for encryption, and Firebase to store data such as encrypted keypairs and messages.
When a user first signs up, an ECDH keypair is generated, and a master key is generated using their password and a random salt with argon2. Then, the private key is wrapped using the master key with AES-CBC, and converted to a base64 string. The public key is exported, and along with the salt, is also converted to base64 strings, then both keys and the salt are sent to Firebase for storage. The master key is stored locally using indexeddb, then the user is redirected to the home page
When a user logs in, the salt is fetched from Firebase and converted to Uint8Array. It is then used with the password to generate the master key, which is then stored in indexeddb. After this, the user is redirected to the home page
When a user is on the home page, their public and private keys are fetched from Firebase, and converted into ArrayBuffers for use with WebCrypto. The private key is unwrapped using the master key, and the public key is imported. The public and private keys can now be used to compute a secret that can be used for encrypting/decrypting messages
When a user logs out, the master key in indexeddb is destroyed
When a user signs up for the first time and is redirected to the home page, the private key is fetched from Firebase and unwrapped with no issues. However, after they log out, if they try logging in again, then the private key can't be unwrapped for some reason. I get an Uncaught (in promise) Error
error when I am redirected to the home page:
I've tried printing out the error message but all I get is "Error". I've managed to track the issue down to when I am using unwrapKey to unwrap the private key, however I have no idea why it can't unwrap the key.
This is the code for generating ECDH keypair, wrapping and unwrapping the key, as well as generating the master key. The encode and decode functions are to convert the ArrayBuffer to base64 string and vice versa:
const webCrypto = window.crypto.subtle;
export const createKeys = () => {
return webCrypto.generateKey(
{
name: "ECDH",
namedCurve: "P-256", //can be "P-256", "P-384", or "P-521"
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["deriveKey", "deriveBits"] //can be any combination of "deriveKey" and "deriveBits"
).then((key) => {
return key;
}).catch((err) => {
console.error(err);
});
}
export const encryptPrivateKey = async (masterKey, salt, privateKey) => {
// generate iv to use with encryption and decryption
// note that this can be stored in firebase without needing to be encrypted
const iv = window.crypto.getRandomValues(new Uint8Array(16));
// encrypt privateKey with mk using AES256
const encryptKey = await webCrypto.wrapKey(
"jwk", //can be "jwk", "raw", "spki", or "pkcs8"
privateKey, //the key you want to wrap, must be able to export to above format
masterKey, //the AES-CBC key with "wrapKey" usage flag
{ //these are the wrapping key's algorithm options
name: "AES-CBC",
//Don't re-use initialization vectors!
//Always generate a new iv every time your encrypt!
iv: iv,
}
)
// need to decode encryptKey, salt and iv from arrayBuffer to string to be able to store in firebase
return {
key: encode(encryptKey),
salt: encode(salt),
iv: encode(iv),
};
}
export const decryptPrivateKey = async (masterKey, encryptKey) => {
// need to encode encryptKey, salt and iv from string to arrayBuffer to be able to decrypt
const key = decode(encryptKey.key);
const iv = decode(encryptKey.iv);
// decrypt privateKey with mk using AES256
const decryptKey = await webCrypto.unwrapKey(
"jwk", //"jwk", "raw", "spki", or "pkcs8" (whatever was used in wrapping)
key, //the key you want to unwrap
masterKey, //the AES-CBC key with "unwrapKey" usage flag
{ //these are the wrapping key's algorithm options
name: "AES-CBC",
iv: iv, //The initialization vector you used to encrypt
},
{ //this what you want the wrapped key to become (same as when wrapping)
name: "ECDH",
namedCurve: "P-256", //can be "P-256", "P-384", or "P-521"
},
true, //whether the key is extractable (i.e. can be used in exportKey)
["deriveKey", "deriveBits"] //the usages you want the unwrapped key to have
)
return decryptKey;
}
export const generateMasterKey = async (password, salt) => {
// convert hex to Uint8Array
const fromHexString = (hexString) => Uint8Array.from(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
const masterKeyRaw = await argon2id({
password: password, // password (or message) to be hashed
salt: salt, // salt (usually containing random bytes)
parallelism: 1, // degree of parallelism
iterations: 256, // number of iterations to perform
memorySize: 512, // amount of memory to be used in kibibytes (1024 bytes)
hashLength: 32, // 256 bits, output size in bytes
outputType: 'hex', // 'hex' | 'binary' | 'encoded', by default returns hex string
}).then(res => {
const hex = fromHexString(res);
return hex;
});
// convert masterKey to cryptoKey format
const masterKey = await webCrypto.importKey(
"raw",
masterKeyRaw,
"AES-CBC",
true,
["encrypt", "decrypt", "wrapKey", "unwrapKey"]
)
return masterKey;
}
I tried using the exact same functions to mock two users signing up and sending encrypted messages to each other using the shared secret from their ECDH keys, and it works fine, so I'm not sure why I'm getting this error only when logging in. Any help would be greatly appreciated!
EDIT: here is some test data for the success case after signing up:
private key before unwrap:
{
0: 45
1: 75
2: 149
3: 38
4: 180
5: 23
6: 186
7: 238
8: 12
9: 66
10: 33
11: 232
12: 153
13: 34
14: 225
15: 238
16: 77
17: 73
18: 38
19: 143
20: 163
21: 255
22: 215
23: 76
24: 198
25: 138
26: 53
27: 250
28: 5
29: 123
30: 56
31: 69
32: 77
33: 250
34: 237
35: 181
36: 60
37: 61
38: 27
39: 157
40: 54
41: 143
42: 6
43: 95
44: 158
45: 133
46: 179
47: 88
48: 105
49: 93
50: 126
51: 122
52: 0
53: 199
54: 236
55: 135
56: 22
57: 153
58: 201
59: 116
60: 173
61: 39
62: 230
63: 82
64: 232
65: 11
66: 103
67: 250
68: 254
69: 171
70: 60
71: 6
72: 186
73: 24
74: 202
75: 177
76: 242
77: 14
78: 130
79: 45
80: 90
81: 100
82: 174
83: 198
84: 250
85: 77
86: 216
87: 141
88: 18
89: 27
90: 107
91: 19
92: 146
93: 119
94: 203
95: 138
96: 212
97: 105
98: 34
99: 10
100: 110
101: 181
102: 24
103: 86
104: 100
105: 165
106: 211
107: 37
108: 244
109: 162
110: 15
111: 56
112: 225
113: 64
114: 50
115: 235
116: 186
117: 247
118: 126
119: 197
120: 249
121: 25
122: 116
123: 243
124: 167
125: 205
126: 31
127: 171
128: 185
129: 197
130: 19
131: 48
132: 151
133: 183
134: 142
135: 237
136: 50
137: 44
138: 142
139: 181
140: 42
141: 175
142: 83
143: 185
144: 59
145: 218
146: 106
147: 68
148: 150
149: 240
150: 67
151: 94
152: 244
153: 45
154: 194
155: 163
156: 60
157: 116
158: 65
159: 213
160: 187
161: 231
162: 22
163: 63
164: 19
165: 123
166: 143
167: 57
168: 51
169: 214
170: 64
171: 53
172: 181
173: 109
174: 106
175: 195
176: 139
177: 238
178: 198
179: 75
180: 99
181: 220
182: 76
183: 88
184: 196
185: 2
186: 129
187: 38
188: 186
189: 177
190: 205
191: 203
192: 67
193: 78
194: 233
195: 45
196: 142
197: 2
198: 83
199: 21
200: 89
201: 108
202: 178
203: 216
204: 239
205: 141
206: 77
207: 60
208: 14
209: 200
210: 161
211: 224
212: 27
213: 93
214: 153
215: 96
216: 144
217: 166
218: 161
219: 184
220: 77
221: 143
222: 145
223: 149
224: 186
225: 170
226: 162
227: 231
228: 134
229: 242
230: 17
231: 199
232: 71
233: 231
234: 244
235: 178
236: 222
237: 163
238: 86
239: 53
}
iv used in unwrap:
{
0: 233
1: 252
2: 143
3: 217
4: 252
5: 149
6: 158
7: 55
8: 240
9: 198
10: 231
11: 143
12: 128
13: 231
14: 95
15: 161
}
master key used in unwrap:
{
algorithm: {name: 'AES-GCM', length: 256}
extractable: true
type: "secret"
usages: ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']
}
private key after unwrap:
{
algorithm: {name: 'ECDH', namedCurve: 'P-256'}
extractable: true
type: "private"
usages: ['deriveKey', 'deriveBits']
}
And this is for the fail case after logging in:
private key before unwrap:
{
0: 45
1: 75
2: 149
3: 38
4: 180
5: 23
6: 186
7: 238
8: 12
9: 66
10: 33
11: 232
12: 153
13: 34
14: 225
15: 238
16: 77
17: 73
18: 38
19: 143
20: 163
21: 255
22: 215
23: 76
24: 198
25: 138
26: 53
27: 250
28: 5
29: 123
30: 56
31: 69
32: 77
33: 250
34: 237
35: 181
36: 60
37: 61
38: 27
39: 157
40: 54
41: 143
42: 6
43: 95
44: 158
45: 133
46: 179
47: 88
48: 105
49: 93
50: 126
51: 122
52: 0
53: 199
54: 236
55: 135
56: 22
57: 153
58: 201
59: 116
60: 173
61: 39
62: 230
63: 82
64: 232
65: 11
66: 103
67: 250
68: 254
69: 171
70: 60
71: 6
72: 186
73: 24
74: 202
75: 177
76: 242
77: 14
78: 130
79: 45
80: 90
81: 100
82: 174
83: 198
84: 250
85: 77
86: 216
87: 141
88: 18
89: 27
90: 107
91: 19
92: 146
93: 119
94: 203
95: 138
96: 212
97: 105
98: 34
99: 10
100: 110
101: 181
102: 24
103: 86
104: 100
105: 165
106: 211
107: 37
108: 244
109: 162
110: 15
111: 56
112: 225
113: 64
114: 50
115: 235
116: 186
117: 247
118: 126
119: 197
120: 249
121: 25
122: 116
123: 243
124: 167
125: 205
126: 31
127: 171
128: 185
129: 197
130: 19
131: 48
132: 151
133: 183
134: 142
135: 237
136: 50
137: 44
138: 142
139: 181
140: 42
141: 175
142: 83
143: 185
144: 59
145: 218
146: 106
147: 68
148: 150
149: 240
150: 67
151: 94
152: 244
153: 45
154: 194
155: 163
156: 60
157: 116
158: 65
159: 213
160: 187
161: 231
162: 22
163: 63
164: 19
165: 123
166: 143
167: 57
168: 51
169: 214
170: 64
171: 53
172: 181
173: 109
174: 106
175: 195
176: 139
177: 238
178: 198
179: 75
180: 99
181: 220
182: 76
183: 88
184: 196
185: 2
186: 129
187: 38
188: 186
189: 177
190: 205
191: 203
192: 67
193: 78
194: 233
195: 45
196: 142
197: 2
198: 83
199: 21
200: 89
201: 108
202: 178
203: 216
204: 239
205: 141
206: 77
207: 60
208: 14
209: 200
210: 161
211: 224
212: 27
213: 93
214: 153
215: 96
216: 144
217: 166
218: 161
219: 184
220: 77
221: 143
222: 145
223: 149
224: 186
225: 170
226: 162
227: 231
228: 134
229: 242
230: 17
231: 199
232: 71
233: 231
234: 244
235: 178
236: 222
237: 163
238: 86
239: 53
}
iv used in unwrap:
{
0: 233
1: 252
2: 143
3: 217
4: 252
5: 149
6: 158
7: 55
8: 240
9: 198
10: 231
11: 143
12: 128
13: 231
14: 95
15: 161
}
master key used in unwrap:
{
algorithm: {name: 'AES-GCM', length: 256}
extractable: true
type: "secret"
usages: ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']
}
private key after unwrap:
NOT WORKING!!!