UNPKG

urbit-key-generation

Version:

Key derivation and HD wallet generation functions for Urbit.

593 lines (469 loc) 18 kB
const bip32 = require('bip32') const bip39 = require('bip39') const { expect } = require('chai') const fs = require('fs-extra') const jsc = require('jsverify') const lodash = require('lodash') const ob = require('urbit-ob') const util = require('ethereumjs-util') const crypto = require('isomorphic-webcrypto') const kg = require('../src') const objectFromFile = (path) => { const fd = fs.openSync(path, 'r') const contents = fs.readFileSync(fd) fs.closeSync(fd) const text = contents.toString() return JSON.parse(text) } const replicate = (n, g) => jsc.tuple(new Array(n).fill(g)) const buffer256 = replicate(32, jsc.uint8).smap( arr => Buffer.from(arr), buf => Array.from(buf) ) const buffer384 = replicate(48, jsc.uint8).smap( arr => Buffer.from(arr), buf => Array.from(buf) ) const mnemonic = replicate(16, jsc.uint8).smap( bip39.entropyToMnemonic, bip39.mnemonicToEntropy ) // tests describe('toChecksumAddress', () => { it('matches a reference implementation', () => { let prop = jsc.forall(buffer256, buf => { let hashed = kg._keccak256(buf.toString('hex')) let addr = kg._addHexPrefix(hashed.slice(-20).toString('hex')) return util.toChecksumAddress(addr) === kg._toChecksumAddress(addr) }) jsc.assert(prop) }) }) describe('hex prefix utils', () => { it('work as expected', () => { expect(kg._addHexPrefix('0102')).to.equal('0x0102') expect(kg._addHexPrefix('0x0102')).to.equal('0x0102') expect(kg._stripHexPrefix('0x0102')).to.equal('0102') expect(kg._stripHexPrefix('0102')).to.equal('0102') }) }) describe('isGalaxy', () => { const galaxies = jsc.integer(0, 255) const nongalaxies = jsc.integer(256, 4294967295) it('identifies galaxies correctly', () => { let prop = jsc.forall(galaxies, kg._isGalaxy) jsc.assert(prop) }) it('identifies non-galaxies correctly', () => { let prop = jsc.forall(nongalaxies, ship => kg._isGalaxy(ship) === false) jsc.assert(prop) }) }) describe('isPlanet', () => { const planets = jsc.integer(0x00010000, 0xffffffff) const nonplanets = jsc.integer(0x00000000, 0x0000ffff) it('identifies planets correctly', () => { let prop = jsc.forall(planets, kg._isPlanet) jsc.assert(prop) }) it('identifies non-planets correctly', () => { let prop = jsc.forall(nonplanets, ship => kg._isPlanet(ship) === false) jsc.assert(prop) }) }) describe('shard and combine', () => { it('does not shard non-384-bit tickets', () => { let ticket = '~doznec-marbud' expect(kg.shard(ticket)).to.have.lengthOf(1) }) it('shards 384-bit tickets', () => { let ticket = '~wacfus-dabpex-danted-mosfep-pasrud-lavmer-nodtex-taslus-pactyp-milpub-pildeg-fornev-ralmed-dinfeb-fopbyr-sanbet-sovmyl-dozsut-mogsyx-mapwyc-sorrup-ricnec-marnys-lignex' expect(kg.shard(ticket)).to.have.lengthOf(3) }) it('produces valid shards', () => { let prop = jsc.forall(buffer384, buf => { let ticket = ob.hex2patq(buf.toString('hex')) let shards = kg.shard(ticket) return ( kg.combine([shards[0], shards[1], undefined]) === ticket && kg.combine([shards[0], undefined, shards[2]]) === ticket && kg.combine([undefined, shards[1], shards[2]]) === ticket ) }) jsc.assert(prop) }) it('throws when insufficient shards', () => { expect(() => kg.combine([undefined, undefined, undefined])).to.throw() }) }) describe('argon2u', () => { it('produces 32 bytelength hashes', async function() { this.timeout(10000) let res = await kg.argon2u('my rad entropy', 0) expect(res).to.not.be.undefined expect(res).to.have.lengthOf(32) }) it('uses the ship number as a salt', async function() { this.timeout(10000) let res0 = await kg.argon2u('my rad entropy', 0) let res1 = await kg.argon2u('my rad entropy', 1) expect(res0).to.not.equal(res1) }) }) describe('sha256', () => { const sha256 = (...args) => { const buffer = Buffer.concat(args.map(x => Buffer.from(x))) return crypto.subtle.digest({ name: 'SHA-256' }, buffer) } it('produces 256-bit digests', () => { let prop = jsc.forall(jsc.string, str => { let digest = kg._sha256(str) return digest.byteLength === 32 }) jsc.assert(prop) }) it('works as expected', () => { let helloHash = '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' let hash = kg._sha256('hello') let hashHex = Buffer.from(hash).toString('hex') expect(hashHex).to.equal(helloHash) }) it('matches our old isomorphic-webcrypto-based implementation', () => { let prop = jsc.forall(jsc.string, async str => { let hash0 = kg._sha256(str) let hash1 = await sha256(str) let hex0 = Buffer.from(hash0).toString('hex') let hex1 = Buffer.from(hash1).toString('hex') return hex0 === hex1 }) jsc.assert(prop) }) }) describe('deriveNodeSeed', () => { let types = Object.values(kg.CHILD_SEED_TYPES) let nonNetworkSeedType = jsc.oneof( types .filter(type => type !== kg.CHILD_SEED_TYPES.NETWORK) .map(jsc.constant) ) let config = jsc.record({ seed: buffer256, type: nonNetworkSeedType, }) it('produces valid BIP39 mnemonics for non-network seeds', () => { let prop = jsc.forall(config, cfg => { let { seed, type } = cfg let child = kg.deriveNodeSeed(seed, type) return bip39.validateMnemonic(child) }) jsc.assert(prop) }) it('uses the seed type to salt the master seed', () => { let prop = jsc.forall(config, cfg0 => { let { seed, type } = cfg0 let seed0 = kg.deriveNodeSeed(seed, type) let seed1 = kg.deriveNodeSeed(seed, 'bollocks') return lodash.isEqual(seed0, seed1) === false }) jsc.assert(prop) }) it('works as expected', () => { let seed = Buffer.from('b2bdf8de8452b18f02195b6e7bfc82b900fbcc25681f07ae10f38f11e5af53af', 'hex') let child = kg.deriveNodeSeed(seed, 'management') let mnem = 'bonus favorite swallow panther frequent random essence loop motion apology skull ginger subject exchange please series meadow tree latin smile bring process excite tornado' expect(child).to.equal(mnem) child = kg.deriveNodeSeed(seed, 'ownership') mnem = 'impact keep magnet two rice country girl jungle cabin mystery usual tree horn skull winter palace supreme reform sphere cabbage cry athlete puppy misery' expect(child).to.equal(mnem) }) }) describe('deriveNodeKeys', () => { const VALID_PATH = "m/44'/60'/0'/0/0" const INVALID_PATH = "m/44'/60/0'/0/0" it('derives by paths correctly', async function() { this.timeout(20000) let prop = jsc.forall(mnemonic, mnem => { let seed = bip39.mnemonicToSeed(mnem) let hd = bip32.fromSeed(seed) let wallet0 = hd.derivePath(VALID_PATH) let wallet1 = hd.derivePath(INVALID_PATH) let keys0 = { public: wallet0.publicKey.toString('hex'), private: wallet0.privateKey.toString('hex'), chain: wallet0.chainCode.toString('hex'), address: kg.addressFromSecp256k1Public(wallet0.publicKey.toString('hex')) } let keys1 = { public: wallet1.publicKey.toString('hex'), private: wallet1.privateKey.toString('hex'), chain: wallet1.chainCode.toString('hex'), address: kg.addressFromSecp256k1Public(wallet1.publicKey.toString('hex')) } let node = kg.deriveNodeKeys(mnem, VALID_PATH) return keys0.public === node.public && keys0.private === node.private && keys0.chain === node.chain && keys0.address === node.address && keys1.public !== node.public && keys1.private !== node.private && keys1.chain !== node.chain && keys1.address !== node.address }) jsc.assert(prop) }) it('has the correct properties', () => { let prop = jsc.forall(mnemonic, mnem => { let node = kg.deriveNodeKeys(mnem, VALID_PATH) return 'public' in node && 'private' in node && 'chain' in node && 'address' in node }) }) it('works as expected', () => { let node = kg.deriveNodeKeys( 'market truck nice joke upper divide spot essay mosquito mushroom buzz undo', VALID_PATH ) let expected = { public: '0208489b1c97859b10106f2019d8fe0c64fc6c3439fdbe99a81c016cfe33e902bc', private: 'fc4475d16c797542d3e6c0907a6bdff81aed9c1efa8e5c2b82bc72d36e8de1b2', chain: '51ede5795e85de1f6b4032b152704f1fca125402f9fe1835fc2a82863f617125', address: '0xB8517352a8F1DDe913b191CCDB0D2124e95983a3' } expect(lodash.isEqual(node, expected)).to.equal(true) }) }) describe('generateCode', () => { it('generates the correct +code', () => { let seed = Buffer.from('88359ba61d766e1c2ec9598831668d4233b0f8f58b29da8cf33d25b2590d62a0', 'hex') let keys = kg.deriveNetworkKeys(seed) let code = kg.generateCode(keys) let expected = 'fonnyd-namryd-davhep-figter' expect(lodash.isEqual(code, expected)).to.equal(true) keys = { crypt: { private: '8100c20574882be2246af95ab236f206287debe63c5f358370b18e7a2b753f50' }, auth: { private: '66bf4bb40cde14adeae6fe1e436928f7fa09ff3ca798c611e679418fa4fde97b' } } code = kg.generateCode(keys, 1) expected = 'raldev-topnul-mirnut-lablut' expect(lodash.isEqual(code, expected)).to.equal(true) }) }) describe('generateKeyfile', () => { it('generates the correct keyfile', () => { let seed = Buffer.from('88359ba61d766e1c2ec9598831668d4233b0f8f58b29da8cf33d25b2590d62a0', 'hex') let keys = kg.deriveNetworkKeys(seed) let exp01 = '0w5ea.9Qhfa.yzIMA.qtR2v.sLQJz.kndZE.Rimev.3-RVV.Gtvkn.qBA66.q0jio.OPbL5.AIvkh.cpsmA.XHyjP.zViwm.m2onG.oqZ8g.8w0sp' let exp02 = '0wFNh.ey9Vk.ktC4z.jKEjX.B-BIq.yVLJ6.GiNPU.vSLfd.jHWyX.kIwMP.g2qj6.mptUI.BzWy9.zbyQD.tsius.vak2O.Mj2Zj.3nF21.4038p' let exp1m1 = '0w2D5.4W8DB.hhSoi.deWxf.KnWmN.GbC-Q.qFb7f.x~qYY.ReLGb.JiO33.d09Fc.ppBTy.OmfG8.CcKbi.tRN9V.NYFgb.b1cbR.cduA8.4g0ef.Ei0A1' let exp1m2 = '0wkUE.Dh4YG.aeP2h.FTk9Z.O~iSd.hsTSz.l9oVY.fXnDC.FRZht.Gmgop.E1d9z.bcKYm.iNZh4.NBNqj.KK9fe.fBa1p.o9xuF.xHQx0.y01Af.Ei0A1' let obs01 = kg.generateKeyfile(keys, 0, 1) let obs02 = kg.generateKeyfile(keys, 0, 2) let obs1m1 = kg.generateKeyfile(keys, 1000000, 1) let obs1m2 = kg.generateKeyfile(keys, 1000000, 2) expect(lodash.isEqual(obs01, exp01)).to.equal(true) expect(lodash.isEqual(obs02, exp02)).to.equal(true) expect(lodash.isEqual(obs1m1, exp1m1)).to.equal(true) expect(lodash.isEqual(obs1m2, exp1m2)).to.equal(true) }) }) describe('deriveNetworkKeys', () => { it('matches ++pit:nu:crub:crypto', () => { // to compare w/keygen-hoon: // // ~zod:dojo> /+ keygen // ~zod:dojo> (urbit:sd:keygen (to-byts:keygen 'my-input')) let expected = { crypt: { private: '9c513a22795147661234eea13ee5fa5b1a8b9bed1aa4b1cf87f6bcf353afa8bb', public: 'f7187602dff5e3eea27b4c46368601106916d704a2ef2e451838d4ea80b395e0' }, auth: { private: '52c830cd009a4c6599778b258fa8898cb8b49dd71279c7ca502cb04c2f530d7a', public: '9a09b7e816467b10ebdcd47d71861a2d0896189f2e66690b636ad3bdf8fcc343' } } let seed = Buffer.from('88359ba61d766e1c2ec9598831668d4233b0f8f58b29da8cf33d25b2590d62a0', 'hex') let keys = kg.deriveNetworkKeys(seed) expect(lodash.isEqual(keys, expected)).to.equal(true) seed = Buffer.from('52dc7422d68c0209610502e71009d6e9f054da2accafb612180c853f3f77d606', 'hex'); keys = kg.deriveNetworkKeys(seed) expected = { crypt: { private: '972bd3bc056ac414a3c38bc948a74506120b2fc84ae22ba605c9eeef2b7cc1db', public: 'fadea0a37e6d111eb0806648fa66d689f200f38cfd1e3fe683fc148d76c69d27' }, auth: { private: '25619a9ce9458f869f064ca5693785283a20aa7a8433baf5fcccbb0a84f76cae', public: '76fa3a858dfeb18fd7cd1a63b2fe84f95840e3e5b19e0e6950692cbdc55f3821' } } expect(lodash.isEqual(keys, expected)).to.equal(true) }) it('contains the expected fields', () => { let prop = jsc.forall(jsc.nat, int => { let keys = kg.deriveNetworkKeys(int.toString(16)) return 'auth' in keys && 'crypt' in keys && 'public' in keys.auth && 'private' in keys.auth && 'public' in keys.crypt && 'private' in keys.crypt }) jsc.assert(prop, { tests: 50 }) }) it('crashes for invalid hex, but counts empty as valid', () => { try { kg.deriveNetworkKeys('invalid') } catch(e) { expect(e.message.slice(0, 18)).to.equal('invalid hex string') } kg.deriveNetworkKeys('') }) }) describe('ethereum addresses from keys', () => { it('derives correct addresses', () => { const checkAddress = config => { let { epriv, epub, eaddr } = config let fromPub = kg.addressFromSecp256k1Public(epub) expect(fromPub === eaddr).to.equal(true) } let config = { epriv: '0000000000000000000000000000000000000000000000000000000000000001', epub: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', eaddr: '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf' } checkAddress(config) config = { epriv: 'b205a1e03ddf50247d8483435cd91f9c732bad281ad420061ab4310c33166276', epub: '036cb84859e85b1d9a27e060fdede38bb818c93850fb6e42d9c7e4bd879f8b9153', eaddr: '0xAFdEfC1937AE294C3Bd55386A8b9775539d81653' } checkAddress(config) config = { epriv: '44b9abf2708d9adeb1722dcc1e61bef14e5611dee710d66f106e356a111bef90', epub: '02cabb8a3a73ea4a03d025a6ac2ebbbb19a545e4fb10e791ec9b5c942d77aa2076', eaddr: '0xa0784ba3fcea41fD65a7A47b4cc1FA4C3DaA326f' } checkAddress(config) config = { epriv: '208065a247edbe5df4d86fbdc0171303f23a76961be9f6013850dd2bdc759bbb', epub: '02836b35a026743e823a90a0ee3b91bf615c6a757e2b60b9e1dc1826fd0dd16106', eaddr: '0x0BED7ABd61247635c1973eB38474A2516eD1D884' } checkAddress(config) }) }) describe('deriveNetworkSeed', () => { let single = (mnem, rev) => { let seed = bip39.mnemonicToSeed(mnem) let hash = kg._sha256(seed, kg.CHILD_SEED_TYPES.NETWORK, `${rev}`) return Buffer.from(hash).toString('hex') } let mnem = 'pitch purpose street child humor ability ginger twenty evoke art loyal duck' it('uses SHA-256 on a zero revision number', function() { let sin = single(mnem, 0) let dub = kg.deriveNetworkSeed(mnem, '', 0) expect(sin).to.equal(dub) }) it('uses SHA-256d on nonzero revision numbers', function() { let sin = single(mnem, 1) let dub = kg.deriveNetworkSeed(mnem, '', 1) expect(sin).to.not.equal(dub) }) it('gives different output for nonzero revisions', function() { let one = kg.deriveNetworkSeed(mnem, '', 1) let two = kg.deriveNetworkSeed(mnem, '', 2) expect(one).to.not.equal(two) }) }) describe('generateOwnershipWallet', () => { it('generates wallets as expected', async function() { this.timeout(20000) let config = { ticket: '~doznec-marbud', ship: 1 } let wallet = await kg.generateOwnershipWallet(config) let expected = objectFromFile('./test/assets/wallet0.json') expect(lodash.isEqual(wallet, expected.ownership)).to.equal(true) config = { ticket: '~marbud-tidsev-litsut-hidfep', ship: 65012, boot: true } wallet = await kg.generateOwnershipWallet(config) expected = objectFromFile('./test/assets/wallet1.json') expect(lodash.isEqual(wallet, expected.ownership)).to.equal(true) config = { ticket: '~wacfus-dabpex-danted-mosfep-pasrud-lavmer-nodtex-taslus-pactyp-milpub-pildeg-fornev-ralmed-dinfeb-fopbyr-sanbet-sovmyl-dozsut-mogsyx-mapwyc-sorrup-ricnec-marnys-lignex', passphrase: 'froot loops', ship: 222, revision: 6 } wallet = await kg.generateOwnershipWallet(config) expected = objectFromFile('./test/assets/wallet2.json') expect(lodash.isEqual(wallet, expected.ownership)).to.equal(true) config = { ticket: '~doznec-marbud', ship: 0 } wallet = await kg.generateOwnershipWallet(config) expected = objectFromFile('./test/assets/wallet3.json') expect(lodash.isEqual(wallet, expected.ownership)).to.equal(true) }) }) describe('generateWallet', () => { it('generates wallets as expected', async function() { this.timeout(20000) let config = { ticket: '~doznec-marbud', ship: 1 } let wallet = await kg.generateWallet(config) let expected = objectFromFile('./test/assets/wallet0.json') expect(lodash.isEqual(wallet, expected)).to.equal(true) config = { ticket: '~marbud-tidsev-litsut-hidfep', ship: 65012, boot: true } wallet = await kg.generateWallet(config) expected = objectFromFile('./test/assets/wallet1.json') expect(lodash.isEqual(wallet, expected)).to.equal(true) config = { ticket: '~wacfus-dabpex-danted-mosfep-pasrud-lavmer-nodtex-taslus-pactyp-milpub-pildeg-fornev-ralmed-dinfeb-fopbyr-sanbet-sovmyl-dozsut-mogsyx-mapwyc-sorrup-ricnec-marnys-lignex', passphrase: 'froot loops', ship: 222, revision: 6 } wallet = await kg.generateWallet(config) expected = objectFromFile('./test/assets/wallet2.json') expect(lodash.isEqual(wallet, expected)).to.equal(true) config = { ticket: '~doznec-marbud', ship: 0 } wallet = await kg.generateWallet(config) expected = objectFromFile('./test/assets/wallet3.json') expect(lodash.isEqual(wallet, expected)).to.equal(true) config = { ticket: '~doznec-marbud', ship: 0x00ffffff } wallet = await kg.generateWallet(config) expected = objectFromFile('./test/assets/wallet4.json') expect(lodash.isEqual(wallet, expected)).to.equal(true) }) })