WebCrypto API can't unwrap the same key multiple times

152 Views Asked by At

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:

enter image description here

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!!!
0

There are 0 best solutions below