urbit-key-generation
Version:
Key derivation and HD wallet generation functions for Urbit.
764 lines (684 loc) • 20.1 kB
JavaScript
const argon2 = require('argon2-wasm')
const bip32 = require('bip32')
const bip39 = require('bip39')
const jssha256 = require('js-sha256')
const keccak = require('keccak')
const nacl = require('tweetnacl')
const ob = require('urbit-ob')
const secp256k1 = require('secp256k1')
const noun = require('./nockjs/noun')
const serial = require('./nockjs/serial')
const BigInteger = require('jsbn').BigInteger
const { version, name } = require('../package.json')
const GALAXY_MIN = 0x00000000
const GALAXY_MAX = 0x000000ff
const PLANET_MIN = 0x00010000
const PLANET_MAX = 0xffffffff
const CHILD_SEED_TYPES = {
OWNERSHIP: 'ownership',
TRANSFER: 'transfer',
SPAWN: 'spawn',
VOTING: 'voting',
MANAGEMENT: 'management',
NETWORK: 'network',
BITCOIN_MAINNET: 'bitcoinMainnet',
BITCOIN_TESTNET: 'bitcoinTestnet'
}
const DERIVATION_PATH = "m/44'/60'/0'/0/0"
const BTC_MAINNET_DERIVATION_PATH = "m/84'/0'/0'"
const BTC_TESTNET_DERIVATION_PATH = "m/84'/1'/0'"
const BITCOIN_MAINNET_INFO = {
messagePrefix: '\x18Bitcoin Signed Message:\n',
bech32: 'bc',
bip32: {
public: 0x04b24746,
private: 0x04b2430c,
},
pubKeyHash: 0x00,
scriptHash: 0x05,
wif: 0x80,
}
const BITCOIN_TESTNET_INFO = {
messagePrefix: '\x18Bitcoin Signed Message:\n',
bech32: 'tb',
bip32: {
public: 0x045f1cf6,
private: 0x045f18bc,
},
pubKeyHash: 0x6f,
scriptHash: 0xc4,
wif: 0xef,
}
const NETWORK_KEY_CURVE_PARAMETER = '42'
/**
* Add a hex prefix to a string, if one isn't already present.
*
* @param {String} hex
* @return {String}
*/
const addHexPrefix = hex =>
hex.slice(0, 2) === '0x'
? hex
: '0x' + hex
/**
* Strip a hex prefix from a string, if it's present.
*
* @param {String} hex
* @return {String}
*/
const stripHexPrefix = hex =>
hex.slice(0, 2) === '0x'
? hex.slice(2)
: hex
/**
* Hex string into Buffer, accounting for leading 0x and missing leading zeroes,
* crashing if hex is invalid.
*
* @param {String} hex
* @return {Buffer}
*/
const hexToBuffer = hex => {
let cleanHex = stripHexPrefix(hex)
if (typeof cleanHex === 'string' && (cleanHex.length % 2) === 1) {
cleanHex = '0' + cleanHex
}
const buf = Buffer.from(cleanHex, 'hex')
if (cleanHex.length !== 0 && buf.length === 0) {
throw new Error('invalid hex string: ' + hex)
}
return buf
}
/**
* Base-64 encode a buffer.
*
* @param {Buffer} buf
* @return {String}
*/
const b64 = buf => {
let hex = buf.reverse().toString('hex')
let n = new BigInteger(hex, 16)
let c = []
while (n.compareTo(BigInteger.ZERO) > 0) {
c.push(parseInt(n.and(new BigInteger('3f', 16))))
n = n.shiftRight(new BigInteger('6'))
}
const trans = j =>
10 > j
? j + 48
: 36 > j
? j + 87
: 62 > j
? j + 29
: 62 === j
? 45
: 126
return (
'0w' +
c.reduce(
(a, b, i) =>
String.fromCharCode(trans(b)) + (i && 0 === i % 5 ? '.' : '') + a,
''
)
)
}
/**
* Jam a noun.
*
* @param {Noun} seed
* @return {Buffer}
*/
const jam = seed => {
const hex = serial
.jam(seed)
.toString()
.slice(2)
const pad = hex.length % 2 === 0 ? hex : '0' + hex
return Buffer.from(pad, 'hex').reverse()
}
/**
* Keccak-256 hash function.
*
* @param {String} str
* @return {String}
*/
const keccak256 = str =>
keccak('keccak256').update(str).digest()
/**
* Convert an Ethereum address to a checksummed Ethereum address.
*
* @param {String} address an Ethereum address
* @return {String} checksummed address
*/
const toChecksumAddress = (address) => {
const addr = stripHexPrefix(address).toLowerCase()
const hash = keccak256(addr).toString('hex')
const arr = Array.from(addr)
return arr.reduce((acc, char, idx) =>
parseInt(hash[idx], 16) >= 8
? acc + char.toUpperCase()
: acc + char,
'0x'
)
}
/**
* Check if a ship is a galaxy.
* @param {Number} ship
* @return {Bool} true if galaxy, false otherwise
*/
const isGalaxy = ship =>
Number.isInteger(ship) && ship >= GALAXY_MIN && ship <= GALAXY_MAX
/**
* Check if a ship is a planet.
* @param {Number} ship
* @return {Bool} true if planet, false otherwise
*/
const isPlanet = ship =>
Number.isInteger(ship) && ship >= PLANET_MIN && ship <= PLANET_MAX
/**
* Convert a hex-encoded secp256k1 public key into an Ethereum address.
*
* @param {String} pub a 33-byte compressed and hex-encoded public key (i.e.,
* including the leading parity byte)
* @return {String} the corresponding Ethereum address
*/
const addressFromSecp256k1Public = pub => {
const compressed = false
const uncompressed = secp256k1.publicKeyConvert(
hexToBuffer(pub),
compressed
)
const chopped = uncompressed.slice(1) // chop parity byte
const hashed = keccak256(chopped)
const addr = addHexPrefix(hashed.slice(-20).toString('hex'))
return toChecksumAddress(addr)
}
/**
* Argon2 key derivation function, with parameters set as per UP8.
*
* The 'ship' argument is used to salt the provided entropy.
*
* @param {Buffer} entropy an entropy Buffer
* @param {Number} ship a 32-bit Urbit ship number
* @return {Promise<Uint8Array>} the derived master seed
*/
const argon2u = async (entropy, ship) => {
const a2u = await argon2.hash({
pass: entropy,
salt: `urbitkeygen${ship}`,
type: argon2.types.Argon2u,
hashLen: 32,
parallelism: 4,
mem: 512000,
time: 1,
})
return a2u.hash
}
/**
* SHA-256 hash function.
*
* @param {Array, ArrayBuffer, Buffer, String} args any number of arguments
* @return {Buffer} the hash, as bytes
*/
const sha256 = (...args) => {
const buffer = Buffer.concat(args.map(x => Buffer.from(x)))
const hashed = jssha256.sha256.array(buffer)
return Buffer.from(hashed)
}
/**
* Derive a BIP39 mnemonic (UP8 child 'seed') for the given node type, using
* the provided master seed as entropy.
*
* @param {Uint8Array} master a master seed
* @param {String} type one of 'ownership', 'transfer', 'spawn', 'voting',
* 'management', 'bitcoinTestnet', 'bitcoinMainnet'
* @return {String} a BIP39 mnemonic
*
*/
const deriveNodeSeed = (master, type) => {
const hash = sha256(master, type)
return bip39.entropyToMnemonic(hash)
}
/**
* Derive a secp256k1 keypair and corresponding Ethereum address from a
* mnemonic and optional passphrase, according to UP8.
*
* @param {String} mnemonic a BIP39 mnemonic
* @param {String} derivationPath wallet derivation path
* @param {String} passphrase an optional passphrase
* @return {Object} the keypair, BIP32 chain code, and Ethereum address
*/
const deriveNodeKeys = (mnemonic, derivationPath, passphrase) => {
const seed = bip39.mnemonicToSeed(mnemonic, passphrase)
const hd = bip32.fromSeed(seed)
const wallet = hd.derivePath(derivationPath)
return {
public: wallet.publicKey.toString('hex'),
private: wallet.privateKey.toString('hex'),
chain: wallet.chainCode.toString('hex'),
address: addressFromSecp256k1Public(wallet.publicKey.toString('hex'))
}
}
/**
* Derive a child mnemonic and its associated secp256k1 keys from a master
* seed, given the provided child type and an optional passphrase.
*
* @param {Uint8Array} master a master seed
* @param {String} type one of 'ownership', 'transfer', 'spawn', 'voting',
* 'management', 'bitcoinTestnet', 'bitcoinMainnet'
* @param {String} derivationPath wallet derivation path
* @param {String} passphrase an optional passphrase
* @return {Object} the child seed and associated keys
*/
const deriveNode = (master, type, derivationPath, passphrase) => {
const mnemonic = deriveNodeSeed(master, type)
const keys = deriveNodeKeys(mnemonic, derivationPath, passphrase)
return {
type: type,
seed: mnemonic,
keys: keys,
derivationPath: derivationPath
}
}
/**
* Derive a network seed using the provided management mnemonic and optional
* passphrase. A provided revision number is also used as a salt.
*
* @param {String} mnemonic the management mnemonic
* @param {String} passphrase an optional passphrase
* @param {Number} revision a revision number
* @return {String} the resulting hex-encoded network seed
*/
const deriveNetworkSeed = (mnemonic, passphrase, revision) => {
const seed = bip39.mnemonicToSeed(mnemonic, passphrase)
const hash = sha256(seed, CHILD_SEED_TYPES.NETWORK, `${revision}`)
// SHA-256d on nonzero revisions to prevent length extension attacks
const dhash = revision === 0 ? hash : sha256(hash)
return dhash.toString('hex')
}
/**
* Derive ed25519 Urbit network keys from the provided network seed.
*
* Note that this matches ++pit:nu:crub:crypto in zuse.
*
* @param {String} seed the hex-encoded network seed
* @return {Object} ed25519 crypt and auth keys
*/
const deriveNetworkKeys = hex => {
const seed = hexToBuffer(hex)
let h = []
nacl.lowlevel.crypto_hash(h, seed.reverse(), seed.length)
const c = Buffer.from(h.slice(32))
const a = Buffer.from(h.slice(0, 32))
const crypt = nacl.sign.keyPair.fromSeed(c)
const cpub = Buffer.from(crypt.publicKey)
const auth = nacl.sign.keyPair.fromSeed(a)
const apub = Buffer.from(auth.publicKey)
return {
crypt: {
private: c.reverse().toString('hex'),
public: cpub.reverse().toString('hex')
},
auth: {
private: a.reverse().toString('hex'),
public: apub.reverse().toString('hex')
}
}
}
/**
* Derive a network seed and associated ed25519 keys from a management
* mnemonic, revision, and optional passphrase.
*
* @param {String} mnemonic a management mnemonic
* @param {Number} revision a revision number
* @param {String} passphrase an optional passphrase
* @return {Object} the network seed and associated keys
*/
const deriveNetworkInfo = (mnemonic, revision, passphrase) => {
const seed = deriveNetworkSeed(mnemonic, passphrase, revision)
const keys = deriveNetworkKeys(seed)
return {
type: CHILD_SEED_TYPES.NETWORK,
seed: seed,
keys: keys
}
}
/**
* Break a 384-bit ticket into three shards, any two of which can be used to
* recover it. Each shard is simply 2/3 of the ticket -- the first third,
* second third, and first and last thirds concatenated together, respectively.
*
* If provided with a ticket of some other length, it simply returns the ticket
* itself in an array.
*
* @param {String} ticket a 384-bit @q ticket
* @return {Array<String>} the resulting shards
*/
const shard = ticket => {
const ticketHex = ob.patq2hex(ticket)
const ticketBuf = hexToBuffer(ticketHex)
if (ticketBuf.length !== 48) {
return [ ticket ]
}
const shards = [
ticketBuf.slice(0, 32),
ticketBuf.slice(16),
Buffer.concat([ ticketBuf.slice(0, 16), ticketBuf.slice(32) ])
]
const pq = shards.map(shard => ob.hex2patq(shard.toString('hex')))
const combinable =
combine([pq[0], pq[1], undefined]) === ticket &&
combine([pq[0], undefined, pq[2]]) === ticket &&
combine([undefined, pq[1], pq[2]]) === ticket
// shards should always be combinable, so following should be unreachable
/* istanbul ignore next */
if (combinable === false) {
/* istanbul ignore next */
throw new Error('produced invalid shards -- please report this as a bug')
}
return pq
}
/**
* Combine two of three shards to recompute the original secret.
*
* @param {Array<String>} shards an array of shards, in their appropriate
* order; use 'undefined' to mark a missing shard,
* e.g.
*
* > combine([shard0, undefined, shard2])
*
* @return {String} the original secret
*/
const combine = shards => {
const nundef = shards.reduce((acc, shard) =>
acc + (shard === undefined ? 1 : 0), 0)
if (nundef > 1) {
throw new Error('combine: need at least two shards')
}
const s0 = shards[0]
const s1 = shards[1]
const s2 = shards[2]
return ob.hex2patq(
s0 !== undefined && s1 !== undefined
? ob.patq2hex(s0).slice(0, 32) + ob.patq2hex(s1)
: s0 !== undefined && s2 !== undefined
? ob.patq2hex(s0) + ob.patq2hex(s2).slice(32)
: s1 !== undefined && s2 !== undefined
? ob.patq2hex(s2).slice(0, 32) + ob.patq2hex(s1)
// above throw makes this unreachable
/* istanbul ignore next */
: undefined
)
}
/**
* Create ring from keypair.
*
* @param {Object} pair
* @return {String}
*/
const createRing = pair =>
pair.crypt.private + pair.auth.private + NETWORK_KEY_CURVE_PARAMETER
/**
* Bytewise buffer XOR.
*
* @param {Buffer} a
* @param {Buffer} b
* @return {Uint8Array}
*/
const xor = (a, b) => {
/* istanbul ignore if */
if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) {
console.log('a', a)
console.log('b', b)
throw new Error('only xor buffers!')
}
const length = Math.max(a.byteLength, b.byteLength)
const result = new Uint8Array(length)
for (let i = 0; i < length; i++) {
result[i] = a[i] ^ b[i];
}
return result
}
/**
* Generate a +code from a keypair.
*
* @param {Object} pair a keypair object
* @param {Number} step an optional nonnegative integer
* @return {String}
*/
const generateCode = (pair, step = 0) => {
const hex2buf = hex =>
Buffer.from(hex, 'hex').reverse()
const buf2hex = buf =>
Buffer.from(buf)
.reverse()
.toString('hex')
const shaf = (buf, salt) => {
const shas = (buf, salt) =>
sha256(xor(salt, sha256(buf)))
const result = shas(buf, salt)
const halfway = result.length / 2
const front = result.slice(0, halfway)
const back = result.slice(halfway, result.length)
return xor(front, back)
}
const ring = hex2buf(createRing(pair))
const bsalt = new BigInteger('73736170', 16) // base salt is noun %pass
const esalt = new BigInteger(step.toString())
const salt = hex2buf(bsalt.add(esalt).toString(16))
const hash = sha256(ring)
const result = shaf(hash, salt)
const half = result.slice(0, result.length / 2)
return ob.hex2patp(buf2hex(half)).slice(1)
}
/**
* Generate a keyfile corresponding to a keypair, point, and revision.
*
* @param {Object} pair
* @param {Number} point
* @param {Number} revision
* @return {String}
*/
const generateKeyfile = (pair, point, revision) => {
const ring = createRing(pair)
const bnsec = new BigInteger(ring, 16)
const sed = noun.dwim(
noun.Atom.fromInt(point),
noun.Atom.fromInt(revision),
noun.Atom.fromString(bnsec.toString()),
noun.Atom.yes
)
return b64(jam(sed))
}
/**
* Generate just the ownership branch of an Urbit HD wallet given the
* provided configuration.
*
* Expects an object with the following properties:
*
* @param {String} ticket a 64, 128, or 384-bit @q master ticket
* @param {Number} ship a 32-bit Urbit ship number
* @param {String} passphrase an optional passphrase to use when deriving
* seeds from BIP39 mnemonics
* @return {Promise<Object>}
*/
const generateOwnershipWallet = async config => {
/* istanbul ignore next */
if ('ticket' in config === false) {
throw new Error('generateWallet: no ticket provided')
}
/* istanbul ignore next */
if ('ship' in config === false) {
throw new Error('generateWallet: no ship provided')
}
const { ticket, ship } = config
const passphrase = 'passphrase' in config ? config.passphrase : null
const buf = hexToBuffer(ob.patq2hex(ticket))
const masterSeed = await argon2u(buf, ship)
const node = deriveNode(
masterSeed,
CHILD_SEED_TYPES.OWNERSHIP,
DERIVATION_PATH,
passphrase
)
return node
}
/**
* Generate an Urbit HD wallet given the provided configuration.
*
* Expects an object with the following properties:
*
* @param {String} ticket a 64, 128, or 384-bit @q master ticket
* @param {Number} ship a 32-bit Urbit ship number
* @param {String} passphrase an optional passphrase to use when deriving
* seeds from BIP39 mnemonics
* @param {Number} revision an optional revision number used to generate new
* networking keys (defaults to 0)
* @param {Bool} boot if true, generates network keys for the provided ship
* (defaults to false)
* @return {Promise<Object>}
*/
const generateWallet = async config => {
/* istanbul ignore next */
if ('ticket' in config === false) {
throw new Error('generateWallet: no ticket provided')
}
/* istanbul ignore next */
if ('ship' in config === false) {
throw new Error('generateWallet: no ship provided')
}
const { ticket, ship } = config
const passphrase = 'passphrase' in config ? config.passphrase : null
const revision = 'revision' in config ? config.revision : 0
const boot = 'boot' in config ? config.boot : false
const shards = shard(ticket)
const patp = ob.patp(ship)
const tier = ob.clan(patp)
const buf = hexToBuffer(ob.patq2hex(ticket))
const meta = {
generator: {
name: name,
version: version,
},
spec: "UP8",
ship: ship,
patp: patp,
tier: tier,
passphrase: passphrase
}
const masterSeed = await argon2u(buf, ship)
const ownership = deriveNode(
masterSeed,
CHILD_SEED_TYPES.OWNERSHIP,
DERIVATION_PATH,
passphrase
)
const transfer = deriveNode(
masterSeed,
CHILD_SEED_TYPES.TRANSFER,
DERIVATION_PATH,
passphrase
)
const spawn =
!isPlanet(ship)
? deriveNode(
masterSeed,
CHILD_SEED_TYPES.SPAWN,
DERIVATION_PATH,
passphrase
)
: {}
const voting =
isGalaxy(ship)
? deriveNode(
masterSeed,
CHILD_SEED_TYPES.VOTING,
DERIVATION_PATH,
passphrase
)
: {}
const management = deriveNode(
masterSeed,
CHILD_SEED_TYPES.MANAGEMENT,
DERIVATION_PATH,
passphrase
)
const network =
boot === true
? deriveNetworkInfo(
management.seed,
revision,
passphrase
)
: {}
const bitcoinTestnet = deriveNode(
masterSeed,
CHILD_SEED_TYPES.BITCOIN_TESTNET,
BTC_TESTNET_DERIVATION_PATH,
passphrase
)
bitcoinTestnet.keys =
{
xpub: bip32.fromPublicKey(Buffer.from(bitcoinTestnet.keys.public, 'hex'),
Buffer.from(bitcoinTestnet.keys.chain, 'hex'),
BITCOIN_TESTNET_INFO)
.toBase58(),
xprv: bip32.fromPrivateKey(Buffer.from(bitcoinTestnet.keys.private, 'hex'),
Buffer.from(bitcoinTestnet.keys.chain, 'hex'),
BITCOIN_TESTNET_INFO)
.toBase58()
}
const bitcoinMainnet = deriveNode(
masterSeed,
CHILD_SEED_TYPES.BITCOIN_MAINNET,
BTC_MAINNET_DERIVATION_PATH,
passphrase
)
bitcoinMainnet.keys =
{
xpub: bip32.fromPublicKey(Buffer.from(bitcoinMainnet.keys.public, 'hex'),
Buffer.from(bitcoinMainnet.keys.chain, 'hex'),
BITCOIN_MAINNET_INFO)
.toBase58(),
xprv: bip32.fromPrivateKey(Buffer.from(bitcoinMainnet.keys.private, 'hex'),
Buffer.from(bitcoinMainnet.keys.chain, 'hex'),
BITCOIN_MAINNET_INFO)
.toBase58()
}
return {
meta: meta,
ticket: ticket,
shards: shards,
ownership: ownership,
transfer: transfer,
spawn: spawn,
voting: voting,
management: management,
network: network,
bitcoinTestnet: bitcoinTestnet,
bitcoinMainnet: bitcoinMainnet
}
}
module.exports = {
generateWallet,
generateOwnershipWallet,
deriveNode,
deriveNodeSeed,
deriveNodeKeys,
deriveNetworkInfo,
deriveNetworkSeed,
deriveNetworkKeys,
CHILD_SEED_TYPES,
argon2u,
shard,
combine,
addressFromSecp256k1Public,
generateCode,
generateKeyfile,
_isGalaxy: isGalaxy,
_isPlanet: isPlanet,
_sha256: sha256,
_keccak256: keccak256,
_toChecksumAddress: toChecksumAddress,
_addHexPrefix: addHexPrefix,
_stripHexPrefix: stripHexPrefix
}