Reputation: 771
The Web Crypto API offers the possibility to save a private or public key as a special, opaque type of object in the client's IndexedDB database, i.e., the client and JS runtime can work with the CryptoKey, but they cannot spell it out. Additionally, upon generation or import of said key, one can stipulate that the key be non-extractable.
My goal is to save an individual private key on a user's client device that I would use as his digital signature. It is important for me to know how hard or easy it would be to pass this CryptoKey between devices, how hard it would be for my user to give this CryptoKey to his friend or copy it to another device of his.
Upvotes: 8
Views: 5555
Reputation: 7123
It's possible to export a key in different format (However not all type of key support all format don't know why !). For this to be possible when you generate/import the key you need to specify that the key is extractable as you said. The Web Cryptography API says:
If the [[extractable]] internal slot of key is false, then throw an InvalidAccessError.
However you can export key securely (But some malicious js it your page can also extract it).
For instance if you want to be able to export a ECDSA key:
window.crypto.subtle.generateKey(
{
name: "ECDSA",
namedCurve: "P-256", // the curve name
},
true, // <== Here if you want it to be exportable !!
["sign", "verify"] // usage
)
.then(function(key){
//returns a keypair object
console.log(key);
console.log(key.publicKey);
console.log(key.privateKey);
})
.catch(function(err){
console.error(err);
});
Then you can export the public and private key in JWT. Example for the private key:
window.crypto.subtle.exportKey(
"jwk", // here you can change the format but i think that only jwk is supported for both public and private key. JWK is easier to use later
privateKey
)
.then(function(keydata){
//returns the exported key data
console.log(keydata);
})
.catch(function(err){
console.error(err);
});
Then you can save it in json file and let the user download it and import it later. To add additional security you could ask for a password to encrypt the json file in AES. And forbid export once user imported the key. He/She already has it so it's useless for him to export it again.
To import the key just load the file and import the private or/and public key.
window.crypto.subtle.importKey(
"jwk",
{
kty: myKetPubOrPrivateFromJson.kty,
crv: myKetPubOrPrivateFromJson.crv,
x: myKetPubOrPrivateFromJson.x,
y: myKetPubOrPrivateFromJson.y,
ext: myKetPubOrPrivateFromJson.ext,
},
{
name: "ECDSA",
namedCurve: "P-256", // i think you can change it by myKetPubOrPrivateFromJson.crv not sure about that
},
false, // <== it's useless to be able to export the key again
myKetPubOrPrivateFromJson.key_ops
)
.then(function(publicKey){
//returns a publicKey (or privateKey if you are importing a private key)
console.log(publicKey);
})
.catch(function(err){
console.error(err);
});
It's also possible to use a wrap/unwrap function however it doesn't seem to be possible to use it with ECDSA and ECDH key but here is a quick and DIRTY example (live):
function str2Buffer(data) {
const utf8Str = decodeURI(encodeURIComponent(data));
const len = utf8Str.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) {
arr[i] = utf8Str.charCodeAt(i);
}
return arr.buffer;
}
function buffer2Hex(buffer) {
return Array.from(new Uint8Array(buffer)).map(b => ('00' + b.toString(16)).slice(-2)).join('');
}
function hex2Buffer(data) {
if (data.length % 2 === 0) {
const bytes = [];
for (let i = 0; i < data.length; i += 2) {
bytes.push(parseInt(data.substr(i, 2), 16));
}
return new Uint8Array(bytes).buffer;
} else {
throw new Error('Wrong string format');
}
}
function createAesKey(password, salt) {
const passwordBuf = typeof password === 'string' ? str2Buffer(password) : password;
return window.crypto.subtle.importKey(
'raw',
passwordBuf,
'PBKDF2',
false,
['deriveKey', 'deriveBits']
).then(derivedKey =>
window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: str2Buffer(salt),
iterations: 1000,
hash: { name: 'SHA-512' }
},
derivedKey,
{name: 'AES-CBC', length: 256},
false,
['wrapKey', 'unwrapKey']
)
);
}
function genKeyPair() {
return window.crypto.subtle.generateKey(
{
name: "RSA-PSS",
modulusLength: 2048, //can be 1024, 2048, or 4096
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: {name: "SHA-256"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512"
},
true, // <== Here exportable
["sign", "verify"] // usage
)
}
function exportKey(keyToWrap, wrappingKey) {
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const promise = new Promise(function(resolve, reject) {
window.crypto.subtle.wrapKey(
"jwk",
keyToWrap, //the key you want to wrap, must be able to export to above format
wrappingKey, //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,
}
).then(result => {
const wrap = { key: buffer2Hex(result), iv: buffer2Hex(iv) };
resolve(wrap);
});
});
return promise;
}
function importKey(key, unwrappingKey, iv, usages) {
return window.crypto.subtle.unwrapKey(
"jwk",
key, //the key you want to unwrap
unwrappingKey, //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: "RSA-PSS",
modulusLength: 2048, //can be 1024, 2048, or 4096
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: {name: "SHA-256"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512"
},
false, //whether the key is extractable (i.e. can be used in exportKey)
usages //the usages you want the unwrapped key to have
);
}
createAesKey("password", "usernameassalt").then(aesKey => {
genKeyPair().then(keyPair => {
exportKey(keyPair.publicKey, aesKey)
.then(publicKey => {
exportKey(keyPair.privateKey, aesKey)
.then(privateKey => {
const exportKeys = {publicKey: publicKey, privateKey: privateKey };
appDiv.innerHTML = `AesKey = ${aesKey}<br />
KeyPair: <ul>
<li>publicKey: ${keyPair.publicKey}</li><li>privateKey: ${keyPair.privateKey}</li>
</ul>
Exported: <ul>
<li>publicKey:
<ul>
<li>key: ${exportKeys.publicKey.key}</li>
<li>iv: ${exportKeys.publicKey.iv}</li>
</ul>
</li>
<li>privateKey:
<ul>
<li>key: ${exportKeys.privateKey.key}</li>
<li>iv: ${exportKeys.privateKey.iv}</li>
</ul>
</li>
<ul>`;
importKey(hex2Buffer(exportKeys.privateKey.key), aesKey, hex2Buffer(exportKeys.privateKey.iv), ["sign"]).then(key => console.log(key)).catch(error => console.log(error.message));
});
});
});
});
Upvotes: 3
Reputation: 39261
A key marked as non-extractable can not be exported
The WebCrypto specification is absolutely clear. See section 6 of exportKey
definition
14.3.10. The exportKey method When invoked, the exportKey method MUST perform the following steps:
Let format and key be the format and key parameters passed to the exportKey method, respectively.
Let promise be a new Promise.
Return promise and asynchronously perform the remaining steps.
If the following steps or referenced procedures say to throw an error, reject promise with the returned error and then terminate the algorithm.
If the name member of of the [[algorithm]] internal slot of key does not identify a registered algorithm that supports the export key operation, then throw a NotSupportedError.
If the [[extractable]] internal slot of key is false, then throw an InvalidAccessError.
The keying material must be hidden, even if it is stored into IndexedDB and can not be exported if the key is not extractable, so you can consider that this key can not be replicated in other device
Upvotes: 4