├── pending └── fabo_node-compat ├── src ├── index.ts ├── types.ts ├── cosmos-keys.ts └── cosmos-keystore.ts ├── tslint.json ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── webpack.config.js ├── CHANGELOG.md ├── README.md ├── package.json ├── .circleci └── config.yml └── test ├── keys.spec.ts └── keystore.spec.ts /pending/fabo_node-compat: -------------------------------------------------------------------------------- 1 | [Added] Made library node compatible @AlexBHarley @faboweb -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | export * from './cosmos-keys' 3 | export * from './cosmos-keystore' 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ] 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | lib 9 | compiled 10 | .awcache 11 | .rpt2_cache 12 | docs 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "es2015", 6 | "lib": [ 7 | "es2015", 8 | "es2016", 9 | "es2017", 10 | "dom" 11 | ], 12 | "strict": true, 13 | "sourceMap": true, 14 | "declaration": true, 15 | "allowSyntheticDefaultImports": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "declarationDir": "lib/types", 19 | "outDir": "lib", 20 | "typeRoots": [ 21 | "node_modules/@types" 22 | ] 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface KeyPair { 2 | privateKey: Buffer 3 | publicKey: Buffer 4 | } 5 | export interface Wallet { 6 | privateKey: string 7 | publicKey: string 8 | cosmosAddress: string 9 | } 10 | export interface StoredWallet { 11 | name: string 12 | address: string 13 | wallet: string // encrypted wallet 14 | } 15 | export interface Coin { 16 | denom: string 17 | amount: string 18 | } 19 | export interface Fee { 20 | amount: Coin[] 21 | gas: string 22 | } 23 | export interface StdSignMsg { 24 | chain_id: string 25 | account_number: string 26 | sequence: string 27 | fee: Fee 28 | msgs: any[] 29 | memo: string 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | devtool: "cheap-source-map", 3 | entry: ['./src/index.ts'], 4 | output: { 5 | path: __dirname + '/lib', 6 | filename: 'cosmos-keys.js', 7 | library: 'cosmos-keys', 8 | libraryTarget: 'umd', 9 | umdNamedDefine: true, 10 | globalObject: 'typeof self !== \'undefined\' ? self : this', 11 | }, 12 | resolve: { 13 | extensions: ['.tsx', '.ts', '.js'] 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | loader: 'babel-loader', 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | query: { 22 | presets: ['@babel/preset-env'] 23 | } 24 | }, 25 | { 26 | test: /\.tsx?$/, 27 | use: 'ts-loader', 28 | exclude: /node_modules/ 29 | } 30 | ] 31 | } 32 | } 33 | module.exports = config; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | 10 | ## [0.0.10] - 2019-06-19 11 | 12 | ### Added 13 | 14 | - Added a mechanism to store keys in local storage @faboweb 15 | 16 | ### Fixed 17 | 18 | - Key creation would create sometimes keys of wrong length @faboweb 19 | 20 | ### Repository 21 | 22 | - Made the repository CI ready @faboweb 23 | 24 | ## [0.0.9] - 2019-06-02 25 | 26 | ### Added 27 | 28 | - accept strings as signmessage @faboweb 29 | 30 | ## [0.0.8] - 2019-06-02 31 | 32 | ### Fixed 33 | 34 | - Typo in bundle name caused issues with integration @faboweb 35 | 36 | ## [0.0.7] - 2019-06-02 37 | 38 | ### Fixed 39 | 40 | - Release didn't include correct files @faboweb 41 | - Fixed publishing wrong files @faboweb 42 | 43 | ### Repository 44 | 45 | - Changed license to Apache-2 @faboweb 46 | 47 | ## [0.0.6] - 2019-06-01 48 | 49 | ### Repository 50 | 51 | - Switched to webpack from rollup to simplify repository @faboweb 52 | 53 | ## [0.0.5] - 2019-05-24 54 | 55 | ### Deprecated 56 | 57 | - Removed some data structure relevant code from this library to make it signing only @faboweb 58 | 59 | ## [0.0.4] - 2019-04-14 60 | 61 | ### Added 62 | 63 | - Added changelog @faboweb 64 | 65 | ### Changed 66 | 67 | - Slightly improved API (breaking) @faboweb 68 | - Slimmed repository architecture @faboweb 69 | 70 | ### Fixed 71 | 72 | - Fixed tests @faboweb -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cosmos Keys 2 | 3 | Cosmos Keys is a library for creating keys and signing messages on Cosmos. You can use it to generate keypairs and addresses and to sign messages for the Cosmos Network. 4 | 5 | This library deals with tasks that are considered *security-critical* and should be used very carefully. 6 | 7 | ## Install 8 | 9 | ```bash 10 | yarn add @lunie/cosmos-keys 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### Create a wallet 16 | 17 | ```js 18 | import { getNewWallet } from "@lunie/cosmos-keys" 19 | 20 | const { cosmosAddress, privateKey, publicKey } = getNewWallet() 21 | // Attention: protect the `privateKey` at all cost and never display it anywhere!! 22 | ``` 23 | 24 | ### Import a seed 25 | 26 | ```js 27 | import { generateWalletFromSeed } from "@lunie/cosmos-keys" 28 | 29 | const seed = ...24 seed words here 30 | 31 | const { cosmosAddress, privateKey, publicKey } = generateWalletFromSeed(seed) 32 | // Attention: protect the `privateKey` at all cost and never display it anywhere!! 33 | ``` 34 | 35 | ### Sign a message 36 | 37 | ```js 38 | import { signWithPrivateKey } from "@lunie/cosmos-keys" 39 | 40 | const privateKey = Buffer.from(...) 41 | const signMessage = ... message to sign, generate messages with "@lunie/cosmos-js" 42 | const signature = signWithPrivateKey(signMessage, privateKey) 43 | 44 | ``` 45 | 46 | ### Using with cosmos-js 47 | 48 | ```js 49 | import { signWithPrivateKey } from "@lunie/cosmos-keys" 50 | import Cosmos from "@lunie/cosmos-js" 51 | 52 | const privateKey = Buffer.from(...) 53 | const publicKey = Buffer.from(...) 54 | 55 | // init cosmos sender 56 | const cosmos = Cosmos(STARGATE_URL, ADDRESS) 57 | 58 | // create message 59 | const msg = cosmos 60 | .MsgSend({toAddress: 'cosmos1abcd09876', amounts: [{ denom: 'stake', amount: 10 }}) 61 | 62 | // create a signer from this local js signer library 63 | const localSigner = (signMessage) => { 64 | const signature = signWithPrivateKey(signMessage, privateKey) 65 | 66 | return { 67 | signature, 68 | publicKey 69 | } 70 | } 71 | 72 | // send the transaction 73 | const { included }= await msg.send({ gas: 200000 }, localSigner) 74 | 75 | // await tx to be included in a block 76 | await included() 77 | ``` 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lunie/cosmos-keys", 3 | "version": "0.0.10", 4 | "description": "js version of cosmos wallet signer and address generation", 5 | "keywords": [ 6 | "cosmos", 7 | "cosmos.network", 8 | "cosmos wallet", 9 | "cosmos signer", 10 | "cosmos javascript", 11 | "cosmos sdk", 12 | "cosmos-sdk" 13 | ], 14 | "main": "lib/cosmos-keys.js", 15 | "typings": "lib/types/cosmos-keys.d.ts", 16 | "author": "Fabian Weber , Billy Rennekamp ", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/luniehq/cosmos-keys.git" 20 | }, 21 | "license": "Apache-2.0", 22 | "engines": { 23 | "node": ">=6.0.0" 24 | }, 25 | "files": [ 26 | "lib" 27 | ], 28 | "scripts": { 29 | "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 30 | "lint:fix": "tslint --fix --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 31 | "prebuild": "rimraf lib", 32 | "build": "webpack", 33 | "test": "jest --coverage", 34 | "test:watch": "jest --coverage --watch", 35 | "test:prod": "npm run lint && npm run test -- --no-cache", 36 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 37 | "prepublishOnly": "npm run build", 38 | "log": "simsala log", 39 | "release": "git checkout develop & git pull & git push origin develop:release" 40 | }, 41 | "husky": { 42 | "hooks": { 43 | "pre-push": "lint-prepush" 44 | } 45 | }, 46 | "lint-prepush": { 47 | "base": "develop", 48 | "tasks": { 49 | "{src,test}/**/*.ts": [ 50 | "prettier --write", 51 | "jest --bail --findRelatedTests", 52 | "git add" 53 | ] 54 | } 55 | }, 56 | "jest": { 57 | "transform": { 58 | ".(ts|tsx)": "ts-jest" 59 | }, 60 | "testEnvironment": "node", 61 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 62 | "moduleFileExtensions": [ 63 | "ts", 64 | "tsx", 65 | "js" 66 | ], 67 | "coveragePathIgnorePatterns": [ 68 | "/node_modules/", 69 | "/test/" 70 | ], 71 | "coverageThreshold": { 72 | "global": { 73 | "branches": 0, 74 | "functions": 95, 75 | "lines": 95, 76 | "statements": 95 77 | } 78 | }, 79 | "collectCoverageFrom": [ 80 | "src/*.{js,ts}" 81 | ], 82 | "setupFiles": [ 83 | "jest-localstorage-mock" 84 | ] 85 | }, 86 | "prettier": { 87 | "semi": false, 88 | "singleQuote": true 89 | }, 90 | "devDependencies": { 91 | "@types/bech32": "^1.1.1", 92 | "@types/bip32": "^1.0.2", 93 | "@types/bip39": "^2.4.2", 94 | "@types/crypto-js": "^3.1.43", 95 | "@types/jest": "^23.3.2", 96 | "@types/node": "^10.11.0", 97 | "@types/secp256k1": "^3.5.0", 98 | "coveralls": "^3.0.2", 99 | "cross-env": "^5.2.0", 100 | "husky": "^1.0.1", 101 | "jest": "^23.6.0", 102 | "jest-config": "^23.6.0", 103 | "jest-localstorage-mock": "^2.4.0", 104 | "lint-prepush": "^0.4.1", 105 | "lodash.camelcase": "^4.3.0", 106 | "prettier": "^1.14.3", 107 | "rimraf": "^2.6.2", 108 | "ts-jest": "^23.10.2", 109 | "ts-loader": "^6.0.2", 110 | "ts-node": "^7.0.1", 111 | "tslint": "^5.11.0", 112 | "tslint-config-prettier": "^1.15.0", 113 | "tslint-config-standard": "^8.0.1", 114 | "typedoc": "^0.12.0", 115 | "typescript": "^3.5.1", 116 | "webpack": "^4.32.2", 117 | "webpack-cli": "^3.3.2" 118 | }, 119 | "dependencies": { 120 | "bech32": "^1.1.3", 121 | "bip32": "^1.0.4", 122 | "bip39": "^3.0.1", 123 | "crypto-js": "^3.1.9-1", 124 | "secp256k1": "^3.6.2" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | # reusable commands 4 | commands: 5 | yarn-install: 6 | description: "[YARN] update and install" 7 | steps: 8 | - restore_cache: 9 | keys: 10 | - v4-dependencies-root-{{ checksum "package.json" }} 11 | - v4-dependencies-root- 12 | 13 | - run: yarn install 14 | - save_cache: 15 | paths: 16 | - yarn.lock 17 | - node_modules 18 | key: v4-dependencies-root-{{ checksum "package.json" }} 19 | 20 | jobs: 21 | pendingUpdated: 22 | docker: 23 | - image: circleci/node:10.15.3 24 | steps: 25 | - checkout 26 | - run: yarn add simsala 27 | - run: node node_modules/simsala/src/cli.js check 28 | 29 | lint: 30 | docker: 31 | - image: circleci/node:10.15.3 32 | steps: 33 | - checkout 34 | - yarn-install 35 | - run: yarn run lint 36 | 37 | testUnit: 38 | docker: 39 | - image: circleci/node:10.15.3 40 | steps: 41 | - checkout 42 | - yarn-install 43 | - run: 44 | name: Setup Code Climate test-reporter 45 | command: | 46 | # download test reporter as a static binary 47 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 48 | chmod +x ./cc-test-reporter 49 | - run: 50 | name: Test 51 | command: | 52 | # notify Code Climate of a pending test report using `before-build` 53 | ./cc-test-reporter before-build 54 | yarn run test 55 | # upload test report to Code Climate 56 | ./cc-test-reporter format-coverage -t lcov ./coverage/lcov.info 57 | ./cc-test-reporter upload-coverage 58 | no_output_timeout: 120 59 | 60 | security: 61 | docker: 62 | - image: circleci/node:10.15.3 63 | steps: 64 | - checkout 65 | - run: 66 | name: Audit 67 | command: | 68 | set +e 69 | 70 | SUMMARY="$(yarn audit | grep Severity)" 71 | VULNERABILITIES=".*(High|Critical).*" 72 | 73 | if [[ $SUMMARY =~ $VULNERABILITIES ]]; then 74 | echo "Unsafe dependencies found: $SUMMARY" >&2 75 | exit 1 76 | fi 77 | echo "Your dependencies are secure enough: $SUMMARY" 78 | exit 0 79 | 80 | # Create release. 81 | release: 82 | docker: 83 | - image: circleci/node:10.15.3 84 | steps: 85 | - checkout 86 | - run: | 87 | yarn add simsala 88 | git config user.email "bot@lunie.io" 89 | git config user.name "Lunie Bot" 90 | node node_modules/simsala/src/cli.js release-candidate --semver prerelease --owner luniehq --repository cosmos-js --token $GIT_BOT_TOKEN 91 | 92 | # Push merges to master immediatly back to develop to stay in sync 93 | mergeBack: 94 | docker: 95 | - image: circleci/node:10.15.3 96 | steps: 97 | - checkout 98 | - run: 99 | command: | 100 | git remote add bot https://${GIT_BOT_TOKEN}@github.com/luniehq/lunie.git 101 | git checkout develop 102 | git pull 103 | git merge origin/master 104 | git push 105 | 106 | 107 | workflows: 108 | version: 2 109 | build-and-deploy: 110 | jobs: 111 | # Static checks before 112 | - pendingUpdated: 113 | filters: 114 | branches: 115 | ignore: 116 | - release 117 | - master 118 | 119 | - security: 120 | filters: 121 | branches: 122 | ignore: release 123 | 124 | - lint: 125 | filters: 126 | branches: 127 | ignore: release 128 | 129 | - testUnit: 130 | filters: 131 | branches: 132 | ignore: release 133 | releaseManually: 134 | jobs: 135 | - release: 136 | filters: 137 | branches: 138 | only: 139 | - release 140 | mergeBack: 141 | jobs: 142 | - mergeBack: 143 | filters: 144 | branches: 145 | only: master 146 | -------------------------------------------------------------------------------- /src/cosmos-keys.ts: -------------------------------------------------------------------------------- 1 | import * as bip39 from 'bip39' 2 | import * as bip32 from 'bip32' 3 | import * as bech32 from 'bech32' 4 | import * as secp256k1 from 'secp256k1' 5 | import * as CryptoJS from 'crypto-js' 6 | import { Wallet, StdSignMsg, KeyPair } from './types'; 7 | 8 | const hdPathAtom = `m/44'/118'/0'/0/0` // key controlling ATOM allocation 9 | 10 | /* tslint:disable-next-line:strict-type-predicates */ 11 | const windowObject: Window | null = typeof window === 'undefined' ? null : window 12 | 13 | // returns a byte buffer of the size specified 14 | export function randomBytes(size: number, window = windowObject): Buffer { 15 | // in browsers 16 | if (window && window.crypto) { 17 | return windowRandomBytes(size, window) 18 | } 19 | 20 | try { 21 | // native node crypto 22 | const crypto = require('crypto') 23 | return crypto.randomBytes(size) 24 | } catch (err) { 25 | // no native node crypto available 26 | } 27 | 28 | throw new Error( 29 | 'There is no native support for random bytes on this system. Key generation is not safe here.' 30 | ) 31 | } 32 | 33 | export function getNewWalletFromSeed(mnemonic: string): Wallet { 34 | const masterKey = deriveMasterKey(mnemonic) 35 | const { privateKey, publicKey } = deriveKeypair(masterKey) 36 | const cosmosAddress = getCosmosAddress(publicKey) 37 | return { 38 | privateKey: privateKey.toString('hex'), 39 | publicKey: publicKey.toString('hex'), 40 | cosmosAddress 41 | } 42 | } 43 | 44 | export function getSeed(randomBytesFunc: (size: number) => Buffer = randomBytes): string { 45 | const entropy = randomBytesFunc(32) 46 | if (entropy.length !== 32) throw Error(`Entropy has incorrect length`) 47 | const mnemonic = bip39.entropyToMnemonic(entropy.toString('hex')) 48 | 49 | return mnemonic 50 | } 51 | 52 | export function getNewWallet(randomBytesFunc: (size: number) => Buffer = randomBytes): Wallet { 53 | const mnemonic = getSeed(randomBytesFunc) 54 | return getNewWalletFromSeed(mnemonic) 55 | } 56 | 57 | // NOTE: this only works with a compressed public key (33 bytes) 58 | export function getCosmosAddress(publicKey: Buffer): string { 59 | const message = CryptoJS.enc.Hex.parse(publicKey.toString('hex')) 60 | const address = CryptoJS.RIPEMD160(CryptoJS.SHA256(message) as any).toString() 61 | const cosmosAddress = bech32ify(address, `cosmos`) 62 | 63 | return cosmosAddress 64 | } 65 | 66 | function deriveMasterKey(mnemonic: string): bip32.BIP32 { 67 | // throws if mnemonic is invalid 68 | bip39.validateMnemonic(mnemonic) 69 | 70 | const seed = bip39.mnemonicToSeedSync(mnemonic) 71 | const masterKey = bip32.fromSeed(seed) 72 | return masterKey 73 | } 74 | 75 | function deriveKeypair(masterKey: bip32.BIP32): KeyPair { 76 | const cosmosHD = masterKey.derivePath(hdPathAtom) 77 | const privateKey = cosmosHD.privateKey 78 | const publicKey = secp256k1.publicKeyCreate(privateKey, true) 79 | 80 | return { 81 | privateKey, 82 | publicKey 83 | } 84 | } 85 | 86 | // converts a string to a bech32 version of that string which shows a type and has a checksum 87 | function bech32ify(address: string, prefix: string) { 88 | const words = bech32.toWords(Buffer.from(address, 'hex')) 89 | return bech32.encode(prefix, words) 90 | } 91 | 92 | // produces the signature for a message (returns Buffer) 93 | export function signWithPrivateKey(signMessage: StdSignMsg | string, privateKey: Buffer): Buffer { 94 | const signMessageString: string = 95 | typeof signMessage === 'string' ? signMessage : JSON.stringify(signMessage) 96 | const signHash = Buffer.from(CryptoJS.SHA256(signMessageString).toString(), `hex`) 97 | const { signature } = secp256k1.sign(signHash, privateKey) 98 | 99 | return signature 100 | } 101 | 102 | function windowRandomBytes(size: number, window: Window) { 103 | const chunkSize = size / 4 104 | let hexString = '' 105 | let keyContainer = new Uint32Array(chunkSize) 106 | keyContainer = window.crypto.getRandomValues(keyContainer) 107 | 108 | for (let keySegment = 0; keySegment < keyContainer.length; keySegment++) { 109 | let chunk = keyContainer[keySegment].toString(16) // Convert int to hex 110 | while (chunk.length < chunkSize) { 111 | // fill up so we get equal sized chunks 112 | chunk = '0' + chunk 113 | } 114 | hexString += chunk // join 115 | } 116 | return Buffer.from(hexString, 'hex') 117 | } 118 | -------------------------------------------------------------------------------- /test/keys.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | randomBytes, 3 | getCosmosAddress, 4 | getNewWalletFromSeed, 5 | getSeed, 6 | getNewWallet, 7 | signWithPrivateKey 8 | } from '../src/cosmos-keys' 9 | 10 | describe(`Key Generation`, () => { 11 | it(`randomBytes browser`, () => { 12 | const crypto = require('crypto') 13 | const window = { 14 | crypto: { 15 | getRandomValues: (array: any[]) => crypto.randomBytes(array.length) 16 | } 17 | } 18 | expect(randomBytes(32, window).length).toBe(32) 19 | }) 20 | 21 | it(`randomBytes node`, () => { 22 | expect(randomBytes(32).length).toBe(32) 23 | }) 24 | 25 | it(`randomBytes unsecure environment`, () => { 26 | jest.doMock('crypto', () => null) 27 | 28 | expect(() => randomBytes(32)).toThrow() 29 | }) 30 | 31 | it(`should create a wallet from a seed`, async () => { 32 | expect(await getNewWalletFromSeed(`a b c`)).toEqual({ 33 | cosmosAddress: `cosmos1pt9904aqg739q6p9kgc2v0puqvj6atp0zsj70g`, 34 | privateKey: `a9f1c24315bf0e366660a26c5819b69f242b5d7a293fc5a3dec8341372544be8`, 35 | publicKey: `037a525043e79a9051d58214a9a2a70b657b3d49124dcd0acc4730df5f35d74b32` 36 | }) 37 | }) 38 | 39 | it(`create a seed`, () => { 40 | expect( 41 | getSeed(() => 42 | Buffer.from( 43 | Array(64) 44 | .fill(0) 45 | .join(``), 46 | 'hex' 47 | ) 48 | ) 49 | ).toBe( 50 | `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art` 51 | ) 52 | }) 53 | 54 | it(`create a random wallet`, () => { 55 | expect( 56 | getNewWallet(() => 57 | Buffer.from( 58 | Array(64) 59 | .fill(0) 60 | .join(``), 61 | 'hex' 62 | ) 63 | ) 64 | ).toEqual({ 65 | cosmosAddress: `cosmos1r5v5srda7xfth3hn2s26txvrcrntldjumt8mhl`, 66 | privateKey: `8088c2ed2149c34f6d6533b774da4e1692eb5cb426fdbaef6898eeda489630b7`, 67 | publicKey: `02ba66a84cf7839af172a13e7fc9f5e7008cb8bca1585f8f3bafb3039eda3c1fdd` 68 | }) 69 | }) 70 | 71 | it(`throws an error if entropy function is not producing correct bytes`, () => { 72 | expect(() => 73 | getSeed(() => 74 | Buffer.from( 75 | Array(10) 76 | .fill(0) 77 | .join(``), 78 | 'hex' 79 | ) 80 | ) 81 | ).toThrow() 82 | }) 83 | }) 84 | 85 | describe(`Address generation`, () => { 86 | it(`should create correct cosmos addresses`, () => { 87 | const vectors = [ 88 | { 89 | pubkey: `52FDFC072182654F163F5F0F9A621D729566C74D10037C4D7BBB0407D1E2C64981`, 90 | address: `cosmos1v3z3242hq7xrms35gu722v4nt8uux8nvug5gye` 91 | }, 92 | { 93 | pubkey: `855AD8681D0D86D1E91E00167939CB6694D2C422ACD208A0072939487F6999EB9D`, 94 | address: `cosmos1hrtz7umxfyzun8v2xcas0v45hj2uhp6sgdpac8` 95 | } 96 | ] 97 | vectors.forEach(({ pubkey, address }) => { 98 | expect(getCosmosAddress(Buffer.from(pubkey, 'hex'))).toBe(address) 99 | }) 100 | }) 101 | }) 102 | 103 | describe(`Signing`, () => { 104 | it(`should create a correct signature`, () => { 105 | const vectors = [ 106 | { 107 | privateKey: `2afc5a66b30e7521d553ec8e6f7244f906df97477248c30c103d7b3f2c671fef`, 108 | signMessage: { 109 | account_number: '1', 110 | chain_id: 'tendermint_test', 111 | fee: { amount: [{ amount: '0', denom: '' }], gas: '21906' }, 112 | memo: '', 113 | msgs: [ 114 | { 115 | type: 'cosmos-sdk/Send', 116 | value: { 117 | inputs: [ 118 | { 119 | address: 'cosmos1qperwt9wrnkg5k9e5gzfgjppzpqhyav5j24d66', 120 | coins: [{ amount: '1', denom: 'STAKE' }] 121 | } 122 | ], 123 | outputs: [ 124 | { 125 | address: 'cosmos1yeckxz7tapz34kjwnjxvmxzurerquhtrmxmuxt', 126 | coins: [{ amount: '1', denom: 'STAKE' }] 127 | } 128 | ] 129 | } 130 | } 131 | ], 132 | sequence: '0' 133 | }, 134 | signature: `YjJhlAf7aCnUtLyBNDp9e6LKuNgV7hJC3rmm0Wro5nBsIPVtWzjuobsp/AhR5Kht+HcRF2zBq4AfoNQMIbY6fw==` 135 | } 136 | ] 137 | 138 | vectors.forEach(({ privateKey, signMessage, signature: expectedSignature }) => { 139 | const signature = signWithPrivateKey(signMessage, Buffer.from(privateKey, 'hex')) 140 | expect(signature.toString('base64')).toEqual(expectedSignature) 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /test/keystore.spec.ts: -------------------------------------------------------------------------------- 1 | import { testPassword, getStoredWallet, storeWallet, removeWallet } from '../src/cosmos-keystore' 2 | 3 | const mockWallet = { 4 | cosmosAddress: `cosmos1r5v5srda7xfth3hn2s26txvrcrntldjumt8mhl`, 5 | mnemonic: `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art`, 6 | privateKey: `8088c2ed2149c34f6d6533b774da4e1692eb5cb426fdbaef6898eeda489630b7`, 7 | publicKey: `02ba66a84cf7839af172a13e7fc9f5e7008cb8bca1585f8f3bafb3039eda3c1fdd` 8 | } 9 | const mockWallet2 = Object.assign({}, mockWallet, { 10 | cosmosAddress: `cosmos1r5v5srda7xfth3hn2s26txvrcrntldjumt8mh2` 11 | }) 12 | const mockWallet3 = Object.assign({}, mockWallet, { 13 | cosmosAddress: `cosmos1r5v5srda7xfth3hn2s26txvrcrntldjumt8mh3` 14 | }) 15 | 16 | describe(`Keystore`, () => { 17 | beforeEach(() => { 18 | localStorage.clear() 19 | }) 20 | 21 | it(`stores a wallet`, () => { 22 | storeWallet(mockWallet, 'mock-name', 'mock-password') 23 | expect( 24 | localStorage.getItem(`cosmos-wallets-cosmos1r5v5srda7xfth3hn2s26txvrcrntldjumt8mhl`) 25 | ).toBeDefined() 26 | }) 27 | 28 | it(`stores a collection of wallet names to prevent name collision`, () => { 29 | storeWallet(mockWallet, 'mock-name', 'mock-password') 30 | storeWallet(mockWallet2, 'mock-name2', 'mock-password') 31 | storeWallet(mockWallet3, 'mock-name3', 'mock-password') 32 | expect(JSON.parse(localStorage.getItem(`cosmos-wallets-index`) || '[]')).toEqual([ 33 | { 34 | name: `mock-name`, 35 | address: mockWallet.cosmosAddress 36 | }, 37 | { 38 | name: `mock-name2`, 39 | address: mockWallet2.cosmosAddress 40 | }, 41 | { 42 | name: `mock-name3`, 43 | address: mockWallet3.cosmosAddress 44 | } 45 | ]) 46 | }) 47 | 48 | it(`prevents you from adding a wallet with the same name twice`, () => { 49 | storeWallet(mockWallet, 'mock-name', 'mock-password') 50 | expect(() => storeWallet(mockWallet2, 'mock-name', 'mock-password2')).toThrow() 51 | 52 | expect(JSON.parse(localStorage.getItem(`cosmos-wallets-index`) || '[]')).toEqual([ 53 | { 54 | name: `mock-name`, 55 | address: mockWallet.cosmosAddress 56 | } 57 | ]) 58 | }) 59 | 60 | it(`loads a stored wallet`, () => { 61 | storeWallet(mockWallet, 'mock-name', 'mock-password') 62 | const key = getStoredWallet(mockWallet.cosmosAddress, 'mock-password') 63 | expect(key.privateKey).toBe(mockWallet.privateKey) 64 | }) 65 | 66 | it(`signals if there is no stored wallet for an address`, () => { 67 | expect(() => getStoredWallet(mockWallet.cosmosAddress, 'mock-password')).toThrow() 68 | }) 69 | 70 | it(`signals if the password for the stored wallet is incorrect`, () => { 71 | storeWallet(mockWallet, 'mock-name', 'mock-password') 72 | expect(() => getStoredWallet(mockWallet.cosmosAddress, 'wrong-password')).toThrow() 73 | }) 74 | 75 | it(`tests if a password is correct for a localy stored key`, () => { 76 | storeWallet(mockWallet, 'mock-name', 'mock-password') 77 | expect(() => testPassword(mockWallet.cosmosAddress, 'mock-password')).not.toThrow() 78 | expect(() => testPassword(mockWallet.cosmosAddress, 'wrong-password')).toThrow() 79 | }) 80 | 81 | it(`throws if wallet to test password for is not existent for better error output`, () => { 82 | expect(() => testPassword(mockWallet.cosmosAddress, 'mock-password')).toThrow() 83 | }) 84 | 85 | it(`prevents you from overwriting existing key names`, () => { 86 | storeWallet(mockWallet, 'mock-name', 'mock-password') 87 | expect(() => storeWallet(mockWallet, 'mock-name', 'mock-password')).toThrow() 88 | }) 89 | 90 | it(`prevents you from overwriting existing wallets`, () => { 91 | storeWallet(mockWallet, 'mock-name', 'mock-password') 92 | expect(() => storeWallet(mockWallet, 'mock-name2', 'mock-password')).toThrow() 93 | }) 94 | 95 | it(`removes a wallet`, () => { 96 | storeWallet(mockWallet, 'mock-name', 'mock-password') 97 | storeWallet(mockWallet2, 'mock-name2', 'mock-password') 98 | removeWallet(mockWallet.cosmosAddress, 'mock-password') 99 | expect(() => getStoredWallet(mockWallet.cosmosAddress, 'mock-password')).toThrow() 100 | expect(JSON.parse(localStorage.getItem(`cosmos-wallets-index`) || '[]')).toEqual([ 101 | { 102 | name: `mock-name2`, 103 | address: mockWallet2.cosmosAddress 104 | } 105 | ]) 106 | }) 107 | 108 | it(`throws if the password for a wallet while removing is incorrect`, () => { 109 | storeWallet(mockWallet, 'mock-name', 'mock-password') 110 | expect(() => removeWallet(mockWallet.cosmosAddress, 'wrong-password')).toThrow() 111 | expect(() => getStoredWallet(mockWallet.cosmosAddress, 'mock-password')).not.toThrow() 112 | }) 113 | 114 | it(`gives an error if the wallet to remove doesn't exist for better error outputs`, () => { 115 | expect(() => removeWallet(mockWallet.cosmosAddress, 'mock-password')).toThrow() 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /src/cosmos-keystore.ts: -------------------------------------------------------------------------------- 1 | import { Wallet, StoredWallet } from './types'; 2 | 3 | const CryptoJS = require('crypto-js') 4 | 5 | /* 6 | * This module assists in storing wallets encrypted in localstorage. 7 | * Wallets are stored by address to prevent accidental overwrite. 8 | * Loading and removal are protected by password checks. 9 | * This module also stores an index of all wallets by name for easy querying, 10 | * i.e. to show all wallets available. 11 | */ 12 | 13 | const KEY_TAG = `cosmos-wallets` 14 | 15 | const keySize = 256 16 | const iterations = 100 17 | 18 | // loads and decrypts a wallet from localstorage 19 | export function getStoredWallet(address: string, password: string): Wallet { 20 | const storedWallet = loadFromStorage(address) 21 | if (!storedWallet) { 22 | throw new Error('No wallet found for requested address') 23 | } 24 | 25 | try { 26 | const decrypted = decrypt(storedWallet.wallet, password) 27 | const wallet = JSON.parse(decrypted) 28 | 29 | return wallet 30 | } catch (err) { 31 | throw new Error(`Incorrect password`) 32 | } 33 | } 34 | 35 | // store a wallet encrypted in localstorage 36 | export function storeWallet(wallet: Wallet, name: string, password: string): void { 37 | const storedWallet = loadFromStorage(wallet.cosmosAddress) 38 | if (storedWallet) { 39 | throw new Error("The wallet was already stored. Can't store the same wallet again.") 40 | } 41 | 42 | const ciphertext = encrypt(JSON.stringify(wallet), password) 43 | addToStorage(name, wallet.cosmosAddress, ciphertext) 44 | } 45 | 46 | // store a wallet encrypted in localstorage 47 | export function removeWallet(address: string, password: string): void { 48 | const storedWallet = loadFromStorage(address) 49 | if (!storedWallet) throw new Error('No wallet found for requested address') 50 | 51 | // make sure the user really wants to delete the wallet 52 | // throws if password is incorrect 53 | testPassword(address, password) 54 | 55 | removeFromStorage(address) 56 | } 57 | 58 | // test password by trying to decrypt a key with said password 59 | export function testPassword(address: string, password: string) { 60 | const storedWallet = loadFromStorage(address) 61 | if (!storedWallet) { 62 | throw new Error('No wallet found for request address') 63 | } 64 | 65 | try { 66 | // try to decode and check if is json format to proof that decoding worked 67 | const decrypted = decrypt(storedWallet.wallet, password) 68 | JSON.parse(decrypted) 69 | } catch (err) { 70 | throw new Error('Password for wallet is incorrect') 71 | } 72 | } 73 | 74 | // returns the index of the stored wallets 75 | export function getWalletIndex(): [{ name: string; address: string }] { 76 | return JSON.parse(localStorage.getItem(KEY_TAG + '-index') || '[]') 77 | } 78 | 79 | // loads an encrypted wallet from localstorage 80 | function loadFromStorage(address: string): StoredWallet | null { 81 | const storedKey = localStorage.getItem(KEY_TAG + '-' + address) 82 | if (!storedKey) { 83 | return null 84 | } 85 | return JSON.parse(storedKey) 86 | } 87 | 88 | // stores an encrypted wallet in localstorage 89 | function addToStorage(name: string, address: string, ciphertext: string): void { 90 | addToIndex(name, address) 91 | 92 | const storedWallet: StoredWallet = { 93 | name, 94 | address, 95 | wallet: ciphertext 96 | } 97 | 98 | localStorage.setItem(KEY_TAG + '-' + address, JSON.stringify(storedWallet)) 99 | } 100 | 101 | // removed a wallet from localstorage 102 | function removeFromStorage(address: string): void { 103 | removeFromIndex(address) 104 | localStorage.removeItem(KEY_TAG + '-' + address) 105 | } 106 | 107 | // stores the names of the keys to prevent name collision 108 | function addToIndex(name: string, address: string): void { 109 | const storedIndex = getWalletIndex() 110 | 111 | if (storedIndex.find(({ name: storedName }) => name === storedName)) { 112 | throw new Error(`Key with that name already exists`) 113 | } 114 | 115 | storedIndex.push({ name, address }) 116 | localStorage.setItem(KEY_TAG + '-index', JSON.stringify(storedIndex)) 117 | } 118 | 119 | function removeFromIndex(address: string): void { 120 | const storedIndex = getWalletIndex() 121 | 122 | const updatedIndex = storedIndex.filter(({ address: storedAddress }) => storedAddress !== address) 123 | localStorage.setItem(KEY_TAG + '-index', JSON.stringify(updatedIndex)) 124 | } 125 | 126 | function encrypt(message: string, password: string): string { 127 | const salt = CryptoJS.lib.WordArray.random(128 / 8) 128 | 129 | const key = CryptoJS.PBKDF2(password, salt, { 130 | keySize: keySize / 32, 131 | iterations: iterations 132 | }) 133 | 134 | const iv = CryptoJS.lib.WordArray.random(128 / 8) 135 | 136 | const encrypted = CryptoJS.AES.encrypt(message, key, { 137 | iv: iv, 138 | padding: CryptoJS.pad.Pkcs7, 139 | mode: CryptoJS.mode.CBC 140 | }) 141 | 142 | // salt, iv will be hex 32 in length 143 | // append them to the ciphertext for use in decryption 144 | const transitmessage = salt.toString() + iv.toString() + encrypted.toString() 145 | return transitmessage 146 | } 147 | 148 | function decrypt(transitMessage: string, password: string): string { 149 | const salt = CryptoJS.enc.Hex.parse(transitMessage.substr(0, 32)) 150 | const iv = CryptoJS.enc.Hex.parse(transitMessage.substr(32, 32)) 151 | const encrypted = transitMessage.substring(64) 152 | 153 | const key = CryptoJS.PBKDF2(password, salt, { 154 | keySize: keySize / 32, 155 | iterations: iterations 156 | }) 157 | 158 | const decrypted = CryptoJS.AES.decrypt(encrypted, key, { 159 | iv: iv, 160 | padding: CryptoJS.pad.Pkcs7, 161 | mode: CryptoJS.mode.CBC 162 | }).toString(CryptoJS.enc.Utf8) 163 | return decrypted 164 | } 165 | --------------------------------------------------------------------------------