diff --git a/docs/lib/classes/lumwalletfactory.md b/docs/lib/classes/lumwalletfactory.md index f86e986..df257ee 100644 --- a/docs/lib/classes/lumwalletfactory.md +++ b/docs/lib/classes/lumwalletfactory.md @@ -11,6 +11,7 @@ - [fromKeyStore](LumWalletFactory.md#fromkeystore) - [fromLedgerTransport](LumWalletFactory.md#fromledgertransport) - [fromMnemonic](LumWalletFactory.md#frommnemonic) +- [fromOfflineSigner](LumWalletFactory.md#fromofflinesigner) - [fromPrivateKey](LumWalletFactory.md#fromprivatekey) ## Constructors @@ -81,6 +82,24 @@ Create a LumWallet instance based on a mnemonic and a derivation path ___ +### fromOfflineSigner + +▸ `Static` **fromOfflineSigner**(`offlineSigner`): `Promise`<[`LumWallet`](LumWallet.md)\> + +Create a LumWallet instance based on an OfflineDirectSigner instance compatible with Comsjs based implementations. + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `offlineSigner` | `OfflineDirectSigner` | OfflineDirectSigner instance compatible with Comsjs based implementations | + +#### Returns + +`Promise`<[`LumWallet`](LumWallet.md)\> + +___ + ### fromPrivateKey ▸ `Static` **fromPrivateKey**(`privateKey`, `addressPrefix?`): `Promise`<[`LumWallet`](LumWallet.md)\> diff --git a/docs/lib/enums/lumconstants.lummessagesigner.md b/docs/lib/enums/lumconstants.lummessagesigner.md index e9609c9..c34cd91 100644 --- a/docs/lib/enums/lumconstants.lummessagesigner.md +++ b/docs/lib/enums/lumconstants.lummessagesigner.md @@ -9,6 +9,7 @@ Signing wallets ### Enumeration members - [LEDGER](LumConstants.LumMessageSigner.md#ledger) +- [OFFLINE](LumConstants.LumMessageSigner.md#offline) - [PAPER](LumConstants.LumMessageSigner.md#paper) ## Enumeration members @@ -19,6 +20,12 @@ Signing wallets ___ +### OFFLINE + +• **OFFLINE** = `"lum-sdk/offline"` + +___ + ### PAPER • **PAPER** = `"lum-sdk/paper"` diff --git a/docs/lib/modules/lumconstants.md b/docs/lib/modules/lumconstants.md index d115903..b2527a0 100644 --- a/docs/lib/modules/lumconstants.md +++ b/docs/lib/modules/lumconstants.md @@ -17,6 +17,7 @@ - [LumBech32PrefixValPub](LumConstants.md#lumbech32prefixvalpub) - [LumDenom](LumConstants.md#lumdenom) - [LumExponent](LumConstants.md#lumexponent) +- [LumSignOnlyChainId](LumConstants.md#lumsignonlychainid) - [LumWalletSigningVersion](LumConstants.md#lumwalletsigningversion) - [MicroLumDenom](LumConstants.md#microlumdenom) - [PrivateKeyLength](LumConstants.md#privatekeylength) @@ -104,6 +105,14 @@ Lum Exponent ___ +### LumSignOnlyChainId + +• **LumSignOnlyChainId**: ``"lum-signature-only"`` + +Chain ID used for message signature by wallet implementations that require one + +___ + ### LumWalletSigningVersion • **LumWalletSigningVersion**: ``"1"`` diff --git a/package.json b/package.json index e2b7277..98ae9de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lum-network/sdk-javascript", - "version": "0.5.2", + "version": "0.5.3", "license": "Apache-2.0", "description": "Javascript SDK library for NodeJS and Web browsers to interact with the Lum Network.", "homepage": "https://github.com/lum-network/sdk-javascript#readme", diff --git a/src/constants/index.ts b/src/constants/index.ts index 24b36a8..3055bf9 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -77,4 +77,10 @@ export const LumWalletSigningVersion = '1'; export enum LumMessageSigner { PAPER = 'lum-sdk/paper', LEDGER = 'lum-sdk/ledger', + OFFLINE = 'lum-sdk/offline', } + +/** + * Chain ID used for message signature by wallet implementations that require one + */ +export const LumSignOnlyChainId = 'lum-signature-only'; diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 4434c5f..98e8bce 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -6,7 +6,7 @@ import { makeSignBytes } from '@cosmjs/proto-signing'; import { TxRaw, AuthInfo } from '../codec/cosmos/tx/v1beta1/tx'; import { SignMode } from '../codec/cosmos/tx/signing/v1beta1/signing'; -import { LumMessageSigner } from '../constants'; +import { LumMessageSigner, LumSignOnlyChainId } from '../constants'; import { Fee, Doc, SignDoc, SignMsg, DocSigner } from '../types'; import { LumRegistry } from '../registry'; import { sortJSON } from './commons'; @@ -125,13 +125,22 @@ export const verifySignMsg = async (msg: SignMsg): Promise => { } if (msg.signer === LumMessageSigner.PAPER) { return verifySignature(msg.sig, toAscii(msg.msg), msg.publicKey); + } else if (msg.signer === LumMessageSigner.OFFLINE) { + const signDoc = { + bodyBytes: toAscii(msg.msg), + authInfoBytes: generateAuthInfoBytes([{ accountNumber: 0, sequence: 0, publicKey: msg.publicKey }], { amount: [], gas: '0' }, SignMode.SIGN_MODE_DIRECT), + chainId: LumSignOnlyChainId, + accountNumber: Long.fromNumber(0), + }; + const signedBytes = generateSignDocBytes(signDoc); + return verifySignature(msg.sig, signedBytes, msg.publicKey); } else if (msg.signer === LumMessageSigner.LEDGER) { // Re-generate ledger required amino payload to sign messages // This is basically an empty transaction payload // Same a used in the LumLedgerWallet > signMessage method const msgToSign = { 'account_number': '0', - 'chain_id': 'lum-signature-only', + 'chain_id': LumSignOnlyChainId, 'fee': {}, 'memo': msg.msg, 'msgs': [], diff --git a/src/wallet/LumLedgerWallet.ts b/src/wallet/LumLedgerWallet.ts index 711a942..c5c72e6 100644 --- a/src/wallet/LumLedgerWallet.ts +++ b/src/wallet/LumLedgerWallet.ts @@ -90,7 +90,7 @@ export class LumLedgerWallet extends LumWallet { // that is only provided for basic message signature and verification const msgToSign = { 'account_number': '0', - 'chain_id': 'lum-signature-only', + 'chain_id': LumConstants.LumSignOnlyChainId, 'fee': {}, 'memo': msg, 'msgs': [], diff --git a/src/wallet/LumOfflineSignerWallet.ts b/src/wallet/LumOfflineSignerWallet.ts new file mode 100644 index 0000000..bc4ed16 --- /dev/null +++ b/src/wallet/LumOfflineSignerWallet.ts @@ -0,0 +1,81 @@ +import { OfflineDirectSigner } from '@cosmjs/proto-signing'; +import { SignMode } from '../codec/cosmos/tx/signing/v1beta1/signing'; +import { LumUtils, LumTypes, LumConstants } from '..'; +import { LumWallet } from '.'; +import Long from 'long'; + +export class LumOfflineSignerWallet extends LumWallet { + private readonly offlineSigner: OfflineDirectSigner; + + /** + * Create a LumOfflineSignerWallet instance based on an OfflineDirectSigner instance compatible with Comsjs based + * implementations. + * This constructor is not intended to be used directly as it does not initialize the underlying key pair + * Better use the provided static LumPaperWallet builders + * + * @param mnemonicOrPrivateKey mnemonic (string) used to derive the private key or private key (Uint8Array) + */ + constructor(offlineSigner: OfflineDirectSigner) { + super(); + this.offlineSigner = offlineSigner; + } + + signingMode = (): SignMode => { + return SignMode.SIGN_MODE_DIRECT; + }; + + canChangeAccount = (): boolean => { + return false; + }; + + useAccount = async (): Promise => { + const accounts = await this.offlineSigner.getAccounts(); + if (accounts.length === 0) { + throw new Error('No account available.'); + } + this.publicKey = accounts[0].pubkey; + this.address = accounts[0].address; + return true; + }; + + sign = async (): Promise => { + throw new Error('Feature not supported.'); + }; + + signTransaction = async (doc: LumTypes.Doc): Promise => { + if (!this.address || !this.publicKey) { + throw new Error('No account selected.'); + } + const signerIndex = LumUtils.uint8IndexOf( + doc.signers.map((signer) => signer.publicKey), + this.publicKey as Uint8Array, + ); + if (signerIndex === -1) { + throw new Error('Signer not found in document'); + } + const signDoc = LumUtils.generateSignDoc(doc, signerIndex, this.signingMode()); + const response = await this.offlineSigner.signDirect(this.address, signDoc); + return LumUtils.fromBase64(response.signature.signature); + }; + + signMessage = async (msg: string): Promise => { + if (!this.address || !this.publicKey) { + throw new Error('No account selected.'); + } + const signDoc = { + bodyBytes: LumUtils.toAscii(msg), + authInfoBytes: LumUtils.generateAuthInfoBytes([{ accountNumber: 0, sequence: 0, publicKey: this.getPublicKey() }], { amount: [], gas: '0' }, this.signingMode()), + chainId: LumConstants.LumSignOnlyChainId, + accountNumber: Long.fromNumber(0), + }; + const response = await this.offlineSigner.signDirect(this.address, signDoc); + return { + address: this.getAddress(), + publicKey: this.getPublicKey(), + msg: msg, + sig: LumUtils.fromBase64(response.signature.signature), + version: LumConstants.LumWalletSigningVersion, + signer: LumConstants.LumMessageSigner.OFFLINE, + }; + }; +} diff --git a/src/wallet/LumWalletFactory.ts b/src/wallet/LumWalletFactory.ts index 059fcec..da2ae69 100644 --- a/src/wallet/LumWalletFactory.ts +++ b/src/wallet/LumWalletFactory.ts @@ -1,8 +1,10 @@ import Transport from '@ledgerhq/hw-transport'; +import { OfflineDirectSigner } from '@cosmjs/proto-signing'; import { LumWallet } from './LumWallet'; import { LumLedgerWallet } from './LumLedgerWallet'; import { LumPaperWallet } from './LumPaperWallet'; +import { LumOfflineSignerWallet } from './LumOfflineSignerWallet'; import { LumConstants, LumUtils } from '../'; export class LumWalletFactory { @@ -45,6 +47,17 @@ export class LumWalletFactory { return wallet; }; + /** + * Create a LumWallet instance based on an OfflineDirectSigner instance compatible with Comsjs based implementations. + * + * @param offlineSigner OfflineDirectSigner instance compatible with Comsjs based implementations + */ + static fromOfflineSigner = async (offlineSigner: OfflineDirectSigner): Promise => { + const wallet = new LumOfflineSignerWallet(offlineSigner); + await wallet.useAccount(); + return wallet; + }; + /** * Create a LumWallet instance based on a ledger transport * diff --git a/src/wallet/index.ts b/src/wallet/index.ts index dec338e..a185028 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -1,6 +1,7 @@ import { LumWallet } from './LumWallet'; import { LumPaperWallet } from './LumPaperWallet'; import { LumLedgerWallet } from './LumLedgerWallet'; +import { LumOfflineSignerWallet } from './LumOfflineSignerWallet'; import { LumWalletFactory } from './LumWalletFactory'; -export { LumWallet, LumPaperWallet, LumLedgerWallet, LumWalletFactory }; +export { LumWallet, LumPaperWallet, LumLedgerWallet, LumOfflineSignerWallet, LumWalletFactory }; diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index 7ac34f0..1faf34f 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -1,4 +1,39 @@ -import { LumWallet, LumWalletFactory, LumUtils, LumConstants } from '../src'; +import { AccountData, DirectSignResponse, OfflineDirectSigner } from '@cosmjs/proto-signing'; + +import { SignDoc } from '../src/codec/cosmos/tx/v1beta1/tx'; +import { LumWallet, LumWalletFactory, LumUtils, LumConstants, LumMessages } from '../src'; +import { encodeSecp256k1Signature } from '@cosmjs/amino'; + +class FakeOfflineSigner implements OfflineDirectSigner { + private readonly privateKey: Uint8Array; + + constructor(privateKey: Uint8Array) { + this.privateKey = privateKey; + } + + getAccounts = async (): Promise => { + const publicKey = await LumUtils.getPublicKeyFromPrivateKey(this.privateKey); + return [ + { + pubkey: publicKey, + address: LumUtils.getAddressFromPublicKey(publicKey), + algo: 'secp256k1', + }, + ]; + }; + + signDirect = async (signerAddress: string, signDoc: SignDoc): Promise => { + const publicKey = await LumUtils.getPublicKeyFromPrivateKey(this.privateKey); + const signBytes = LumUtils.generateSignDocBytes(signDoc); + const hashedMessage = LumUtils.sha256(signBytes); + const signature = await LumUtils.generateSignature(hashedMessage, this.privateKey); + const stdSig = encodeSecp256k1Signature(publicKey, signature); + return { + signed: signDoc, + signature: stdSig, + }; + }; +} describe('LumWallet', () => { it('Should be identical from mnemonic, privatekey and keystore recovery', async () => { @@ -10,15 +45,43 @@ describe('LumWallet', () => { const w1 = await LumWalletFactory.fromMnemonic(mnemonic); const w2 = await LumWalletFactory.fromPrivateKey(LumUtils.keyFromHex(privateKey)); const w3 = await LumWalletFactory.fromKeyStore(keystore, 'lumiere'); + const w4 = await LumWalletFactory.fromOfflineSigner(new FakeOfflineSigner(LumUtils.keyFromHex(privateKey))); expect(LumUtils.isAddressValid(w1.getAddress())).toBe(true); expect(LumUtils.isAddressValid(w1.getAddress(), LumConstants.LumBech32PrefixAccAddr)).toBe(true); expect(LumUtils.isAddressValid(w1.getAddress(), undefined)).toBe(true); expect(LumUtils.isAddressValid(w1.getAddress(), 'cosmos')).toBe(false); + + // Create a fake document for signature verification purposes + const doc = { + accountNumber: 1, + chainId: 'lumnetwork-testnet', + fee: { + amount: [{ denom: LumConstants.MicroLumDenom, amount: '1' }], + gas: '1', + }, + memo: 'Not a real transaction', + messages: [LumMessages.BuildMsgSend(w1.getAddress(), w2.getAddress(), [{ denom: LumConstants.MicroLumDenom, amount: '1' }])], + signers: [ + { + accountNumber: 1, + sequence: 1, + publicKey: w1.getPublicKey(), + }, + ], + }; + expect(w1.getAddress()).toEqual(w2.getAddress()); expect(w1.getPublicKey()).toEqual(w2.getPublicKey()); + expect(await w1.signTransaction(doc)).toEqual(await w2.signTransaction(doc)); + expect(w1.getAddress()).toEqual(w3.getAddress()); expect(w1.getPublicKey()).toEqual(w3.getPublicKey()); + expect(await w1.signTransaction(doc)).toEqual(await w3.signTransaction(doc)); + + expect(w1.getAddress()).toEqual(w4.getAddress()); + expect(w1.getPublicKey()).toEqual(w4.getPublicKey()); + expect(await w1.signTransaction(doc)).toEqual(await w4.signTransaction(doc)); const randomPrivateKey = LumUtils.generatePrivateKey(); expect(randomPrivateKey).toHaveLength(LumConstants.PrivateKeyLength); @@ -33,19 +96,30 @@ describe('LumWallet', () => { it('Should be able to sign and verify messages', async () => { const message = 'Lum network is an awesome decentralized protocol'; + const privateKey = '0xb8e62c34928025cdd3aef6cbebc68694b5ad9209b2aff6d3891c8e61d22d3a3b'; const mnemonic = 'surround miss nominee dream gap cross assault thank captain prosper drop duty group candy wealth weather scale put'; + const w1 = await LumWalletFactory.fromMnemonic(mnemonic); const w2 = await LumWalletFactory.fromMnemonic(LumUtils.generateMnemonic()); + const w3 = await LumWalletFactory.fromOfflineSigner(new FakeOfflineSigner(LumUtils.keyFromHex(privateKey))); + + const signedW1 = await w1.signMessage(message); + expect(signedW1.signer).toEqual(LumConstants.LumMessageSigner.PAPER); + expect(signedW1.version).toEqual(LumConstants.LumWalletSigningVersion); + expect(await LumUtils.verifySignMsg(signedW1)).toBeTruthy(); + expect(await LumUtils.verifySignMsg(Object.assign({}, signedW1, { msg: 'Wrong message input' }))).toBeFalsy(); + expect(await LumUtils.verifySignMsg(Object.assign({}, signedW1, { publicKey: w2.getPublicKey() }))).toBeFalsy(); + expect(await LumUtils.verifySignMsg(Object.assign({}, signedW1, { address: w2.getAddress() }))).toBeFalsy(); - const signed = await w1.signMessage(message); + const signedW2 = await w2.signMessage(message); + expect(signedW2.signer).toEqual(LumConstants.LumMessageSigner.PAPER); + expect(signedW2.version).toEqual(LumConstants.LumWalletSigningVersion); + expect(LumUtils.toHex(signedW2.sig)).not.toEqual(LumUtils.toHex(signedW1.sig)); + expect(await LumUtils.verifySignMsg(signedW2)).toBeTruthy(); - const v1 = await LumUtils.verifySignMsg(signed); - expect(v1).toBeTruthy(); - const v2 = await LumUtils.verifySignMsg(Object.assign({}, signed, { msg: 'Wrong message input' })); - expect(v2).toBeFalsy(); - const v3 = await LumUtils.verifySignMsg(Object.assign({}, signed, { publicKey: w2.getPublicKey() })); - expect(v3).toBeFalsy(); - const v4 = await LumUtils.verifySignMsg(Object.assign({}, signed, { address: w2.getAddress() })); - expect(v4).toBeFalsy(); + const signedW3 = await w3.signMessage(message); + expect(signedW3.signer).toEqual(LumConstants.LumMessageSigner.OFFLINE); + expect(signedW3.version).toEqual(LumConstants.LumWalletSigningVersion); + expect(await LumUtils.verifySignMsg(signedW3)).toBeTruthy(); }); });