Skip to content
This repository has been archived by the owner on Jul 21, 2023. It is now read-only.

feat: add exporting/importing of non rsa keys in libp2p-key format #179

Merged
merged 10 commits into from
Aug 5, 2020
18 changes: 10 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
"types": "src/index.d.ts",
"leadMaintainer": "Jacob Heun <jacobheun@gmail.com>",
"browser": {
"./src/aes/ciphers.js": "./src/aes/ciphers-browser.js",
"./src/ciphers/aes-gcm.js": "./src/ciphers/aes-gcm.browser.js",
"./src/hmac/index.js": "./src/hmac/index-browser.js",
"./src/keys/ecdh.js": "./src/keys/ecdh-browser.js",
"./src/aes/ciphers.js": "./src/aes/ciphers-browser.js",
"./src/keys/rsa.js": "./src/keys/rsa-browser.js"
},
"files": [
Expand Down Expand Up @@ -43,21 +44,22 @@
"is-typedarray": "^1.0.0",
"iso-random-stream": "^1.1.0",
"keypair": "^1.0.1",
"multibase": "^0.7.0",
"multibase": "^1.0.1",
"multicodec": "^1.0.4",
"multihashing-async": "^0.8.1",
"node-forge": "^0.9.1",
"pem-jwk": "^2.0.0",
"protons": "^1.0.1",
"protons": "^1.2.1",
"secp256k1": "^4.0.0",
"ursa-optional": "~0.10.1"
"uint8arrays": "^1.0.0",
"ursa-optional": "^0.10.1"
},
"devDependencies": {
"@types/chai": "^4.2.11",
"@types/chai": "^4.2.12",
"@types/chai-string": "^1.4.2",
"@types/dirty-chai": "^2.0.2",
"@types/mocha": "^7.0.1",
"@types/sinon": "^9.0.0",
"aegir": "^22.0.0",
"@types/mocha": "^8.0.1",
"aegir": "^25.0.0",
"benchmark": "^2.1.4",
"chai": "^4.2.0",
"chai-string": "^1.5.0",
Expand Down
89 changes: 89 additions & 0 deletions src/ciphers/aes-gcm.browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use strict'

const concat = require('uint8arrays/concat')
const fromString = require('uint8arrays/from-string')

const webcrypto = require('../webcrypto')

// Based off of code from https://github.com/luke-park/SecureCompatibleEncryptionExamples

/**
*
* @param {object} param0
* @param {string} [param0.algorithm] Defaults to 'aes-128-gcm'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add the defaults in the params, according to the jsdoc docs? This might be useful when we auto generate from jsdoc

* @param {Number} [param0.nonceLength] Defaults to 12 (96-bit)
* @param {Number} [param0.keyLength] Defaults to 16
* @param {string} [param0.digest] Defaults to 'sha256'
* @param {Number} [param0.saltLength] Defaults to 16
* @param {Number} [param0.iterations] Defaults to 32767
* @returns {*}
*/
function create ({
algorithm = 'AES-GCM',
nonceLength = 12,
keyLength = 16,
digest = 'SHA-256',
saltLength = 16,
iterations = 32767
} = {}) {
const crypto = webcrypto.get()
keyLength *= 8 // Browser crypto uses bits instead of bytes

/**
* Uses the provided password to derive a pbkdf2 key. The key
* will then be used to encrypt the data.
*
* @param {Uint8Array} data The data to decrypt
* @param {string} password A plain password
* @returns {Promise<Uint8Array>}
*/
async function encrypt (data, password) { // eslint-disable-line require-await
const salt = crypto.getRandomValues(new Uint8Array(saltLength))
const nonce = crypto.getRandomValues(new Uint8Array(nonceLength))
const aesGcm = { name: algorithm, iv: nonce }

// Derive a key using PBKDF2.
const deriveParams = { name: 'PBKDF2', salt, iterations, hash: { name: digest } }
const rawKey = await crypto.subtle.importKey('raw', fromString(password), { name: 'PBKDF2' }, false, ['deriveKey', 'deriveBits'])
const cryptoKey = await crypto.subtle.deriveKey(deriveParams, rawKey, { name: algorithm, length: keyLength }, true, ['encrypt'])

// Encrypt the string.
const ciphertext = await crypto.subtle.encrypt(aesGcm, cryptoKey, data)
return concat([salt, aesGcm.iv, new Uint8Array(ciphertext)])
}

/**
* Uses the provided password to derive a pbkdf2 key. The key
* will then be used to decrypt the data. The options used to create
* this decryption cipher must be the same as those used to create
* the encryption cipher.
*
* @param {Uint8Array} data The data to decrypt
* @param {string} password A plain password
* @returns {Promise<Uint8Array>}
*/
async function decrypt (data, password) {
const salt = data.slice(0, saltLength)
const nonce = data.slice(saltLength, saltLength + nonceLength)
const ciphertext = data.slice(saltLength + nonceLength)
const aesGcm = { name: algorithm, iv: nonce }

// Derive the key using PBKDF2.
const deriveParams = { name: 'PBKDF2', salt, iterations, hash: { name: digest } }
const rawKey = await crypto.subtle.importKey('raw', fromString(password), { name: 'PBKDF2' }, false, ['deriveKey', 'deriveBits'])
const cryptoKey = await crypto.subtle.deriveKey(deriveParams, rawKey, { name: algorithm, length: keyLength }, true, ['decrypt'])

// Decrypt the string.
const plaintext = await crypto.subtle.decrypt(aesGcm, cryptoKey, ciphertext)
return new Uint8Array(plaintext)
}

return {
encrypt,
decrypt
}
}

module.exports = {
create
}
120 changes: 120 additions & 0 deletions src/ciphers/aes-gcm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use strict'

const crypto = require('crypto')

// Based off of code from https://github.com/luke-park/SecureCompatibleEncryptionExamples

/**
*
* @param {object} param0
* @param {Number} [param0.algorithmTagLength] Defaults to 16
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as below

* @param {Number} [param0.nonceLength] Defaults to 12 (96-bit)
* @param {Number} [param0.keyLength] Defaults to 16
* @param {string} [param0.digest] Defaults to 'sha256'
* @param {Number} [param0.saltLength] Defaults to 16
* @param {Number} [param0.iterations] Defaults to 32767
* @returns {*}
*/
function create ({
algorithmTagLength = 16,
nonceLength = 12,
keyLength = 16,
digest = 'sha256',
saltLength = 16,
iterations = 32767
} = {}) {
const algorithm = 'aes-128-gcm'
/**
*
* @private
* @param {Buffer} data
* @param {Buffer} key
* @returns {Promise<Buffer>}
*/
async function encryptWithKey (data, key) { // eslint-disable-line require-await
const nonce = crypto.randomBytes(nonceLength)

// Create the cipher instance.
const cipher = crypto.createCipheriv(algorithm, key, nonce)

// Encrypt and prepend nonce.
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()])

return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()])
}

/**
* Uses the provided password to derive a pbkdf2 key. The key
* will then be used to encrypt the data.
*
* @param {Buffer} data The data to decrypt
* @param {string|Buffer} password A plain password
* @returns {Promise<Buffer>}
*/
async function encrypt (data, password) { // eslint-disable-line require-await
// Generate a 128-bit salt using a CSPRNG.
const salt = crypto.randomBytes(saltLength)

// Derive a key using PBKDF2.
const key = crypto.pbkdf2Sync(Buffer.from(password), salt, iterations, keyLength, digest)

// Encrypt and prepend salt.
return Buffer.concat([salt, await encryptWithKey(Buffer.from(data), key)])
}

/**
* Decrypts the given cipher text with the provided key. The `key` should
* be a cryptographically safe key and not a plaintext password. To use
* a plaintext password, use `decrypt`. The options used to create
* this decryption cipher must be the same as those used to create
* the encryption cipher.
*
* @private
* @param {Buffer} ciphertextAndNonce The data to decrypt
* @param {Buffer} key
* @returns {Promise<Buffer>}
*/
async function decryptWithKey (ciphertextAndNonce, key) { // eslint-disable-line require-await
// Create buffers of nonce, ciphertext and tag.
const nonce = ciphertextAndNonce.slice(0, nonceLength)
const ciphertext = ciphertextAndNonce.slice(nonceLength, ciphertextAndNonce.length - algorithmTagLength)
const tag = ciphertextAndNonce.slice(ciphertext.length + nonceLength)

// Create the cipher instance.
const cipher = crypto.createDecipheriv(algorithm, key, nonce)

// Decrypt and return result.
cipher.setAuthTag(tag)
return Buffer.concat([cipher.update(ciphertext), cipher.final()])
}

/**
* Uses the provided password to derive a pbkdf2 key. The key
* will then be used to decrypt the data. The options used to create
* this decryption cipher must be the same as those used to create
* the encryption cipher.
*
* @param {Buffer} data The data to decrypt
* @param {string|Buffer} password A plain password
*/
async function decrypt (data, password) { // eslint-disable-line require-await
// Create buffers of salt and ciphertextAndNonce.
const salt = data.slice(0, saltLength)
const ciphertextAndNonce = data.slice(saltLength)

// Derive the key using PBKDF2.
const key = crypto.pbkdf2Sync(Buffer.from(password), salt, iterations, keyLength, digest)

// Decrypt and return result.
return decryptWithKey(ciphertextAndNonce, key)
}

return {
encrypt,
decrypt
}
}

module.exports = {
create
}
29 changes: 12 additions & 17 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export interface PrivateKey {
* of the PKCS SubjectPublicKeyInfo.
*/
id(): Promise<string>;
/**
* Exports the password protected key in the format specified.
*/
export(password: string, format?: "pkcs-8" | string): Promise<string>;
}

export interface Keystretcher {
Expand Down Expand Up @@ -132,9 +136,6 @@ export namespace keys {
hash(): Promise<Buffer>;
}

// Type alias for export method
export type KeyInfo = any;

class RsaPrivateKey implements PrivateKey {
constructor(key: any, publicKey: Buffer);
readonly public: RsaPublicKey;
Expand All @@ -146,13 +147,7 @@ export namespace keys {
equals(key: PrivateKey): boolean;
hash(): Promise<Buffer>;
id(): Promise<string>;
/**
* Exports the key into a password protected PEM format
*
* @param password The password to read the encrypted PEM
* @param format Defaults to 'pkcs-8'.
*/
export(password: string, format?: "pkcs-8" | string): KeyInfo;
export(password: string, format?: string): Promise<string>;
}
function unmarshalRsaPublicKey(buf: Buffer): RsaPublicKey;
function unmarshalRsaPrivateKey(buf: Buffer): Promise<RsaPrivateKey>;
Expand Down Expand Up @@ -180,6 +175,7 @@ export namespace keys {
equals(key: PrivateKey): boolean;
hash(): Promise<Buffer>;
id(): Promise<string>;
export(password: string, format?: string): Promise<string>;
}

function unmarshalEd25519PrivateKey(
Expand Down Expand Up @@ -212,6 +208,7 @@ export namespace keys {
equals(key: PrivateKey): boolean;
hash(): Promise<Buffer>;
id(): Promise<string>;
export(password: string, format?: string): Promise<string>;
}

function unmarshalSecp256k1PrivateKey(
Expand All @@ -234,16 +231,14 @@ export namespace keys {
bits: number
): Promise<PrivateKey>;
export function generateKeyPair(
type: "Ed25519",
bits: number
type: "Ed25519"
): Promise<keys.supportedKeys.ed25519.Ed25519PrivateKey>;
export function generateKeyPair(
export function generateKeyPair(
type: "RSA",
bits: number
): Promise<keys.supportedKeys.rsa.RsaPrivateKey>;
export function generateKeyPair(
type: "secp256k1",
bits: number
export function generateKeyPair(
type: "secp256k1"
): Promise<keys.supportedKeys.secp256k1.Secp256k1PrivateKey>;

/**
Expand Down Expand Up @@ -318,7 +313,7 @@ export namespace keys {
* @param pem Password protected private key in PEM format.
* @param password The password used to protect the key.
*/
function _import(pem: string, password: string): Promise<supportedKeys.rsa.RsaPrivateKey>;
function _import(pem: string, password: string, format?: string): Promise<supportedKeys.rsa.RsaPrivateKey>;
export { _import as import };
}

Expand Down
16 changes: 16 additions & 0 deletions src/keys/ed25519-class.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const errcode = require('err-code')

const crypto = require('./ed25519')
const pbm = protobuf(require('./keys.proto'))
const exporter = require('./exporter')

class Ed25519PublicKey {
constructor (key) {
Expand Down Expand Up @@ -86,6 +87,21 @@ class Ed25519PrivateKey {
const hash = await this.public.hash()
return multibase.encode('base58btc', hash).toString().slice(1)
}

/**
* Exports the key into a password protected `format`
*
* @param {string} password - The password to encrypt the key
* @param {string} [format] - Defaults to 'libp2p-key'.
* @returns {Promise<Buffer>} The encrypted private key
*/
async export (password, format = 'libp2p-key') { // eslint-disable-line require-await
if (format === 'libp2p-key') {
return exporter.export(this.bytes, password)
} else {
throw errcode(new Error(`export format '${format}' is not supported`), 'ERR_INVALID_EXPORT_FORMAT')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a notice for this in the README

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It only has the import

}
}
}

function unmarshalEd25519PrivateKey (bytes) {
Expand Down
22 changes: 22 additions & 0 deletions src/keys/exporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict'

const multibase = require('multibase')
const ciphers = require('../ciphers/aes-gcm')

module.exports = {
/**
* Exports the given PrivateKey as a base64 encoded string.
* The PrivateKey is encrypted via a password derived PBKDF2 key
* leveraging the aes-gcm cipher algorithm.
*
* @param {Buffer} privateKey The PrivateKey protobuf buffer
* @param {string} password
* @returns {Promise<string>} A base64 encoded string
*/
export: async function (privateKey, password) {
const cipher = ciphers.create()
const encryptedKey = await cipher.encrypt(privateKey, password)
const base64 = multibase.names.base64
return base64.encode(encryptedKey)
}
}
Loading