diff --git a/package-lock.json b/package-lock.json index 6eba82b423..2432fcff38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "blakejs": "^1.2.1", "bs58": "^5.0.0", "buffer": "^6.0.3", + "canonicalize": "^2.0.0", "events": "^3.3.0", "isomorphic-ws": "^5.0.0", "json-bigint": "^1.0.0", @@ -4692,6 +4693,11 @@ } ] }, + "node_modules/canonicalize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.0.0.tgz", + "integrity": "sha512-ulDEYPv7asdKvqahuAY35c1selLdzDwHqugK92hfkzvlDCwXRRelDkR+Er33md/PtnpqHemgkuDPanZ4fiYZ8w==" + }, "node_modules/chai": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", diff --git a/package.json b/package.json index 92f395a9b6..b590d965ef 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "blakejs": "^1.2.1", "bs58": "^5.0.0", "buffer": "^6.0.3", + "canonicalize": "^2.0.0", "events": "^3.3.0", "isomorphic-ws": "^5.0.0", "json-bigint": "^1.0.0", diff --git a/src/AeSdkBase.ts b/src/AeSdkBase.ts index 7d954ba413..709639ad79 100644 --- a/src/AeSdkBase.ts +++ b/src/AeSdkBase.ts @@ -168,6 +168,14 @@ export default class AeSdkBase extends AeSdkMethods { return this._resolveAccount(onAccount).signMessage(message, options); } + async signTypedData( + data: Encoded.ContractBytearray, + aci: Parameters[1], + { onAccount, ...options }: { onAccount?: OnAccount } & Parameters[2] = {}, + ): Promise { + return this._resolveAccount(onAccount).signTypedData(data, aci, options); + } + override _getOptions(callOptions: AeSdkMethodsOptions = {}): { onNode: Node; onAccount: AccountBase; diff --git a/src/account/Base.ts b/src/account/Base.ts index 05d8b41cd9..8adbe9a670 100644 --- a/src/account/Base.ts +++ b/src/account/Base.ts @@ -2,6 +2,7 @@ import { Encoded } from '../utils/encoder'; import Node from '../Node'; import CompilerBase from '../contract/compiler/Base'; import { Int } from '../tx/builder/constants'; +import { AciValue, Domain } from '../utils/typed-data'; interface AuthData { fee?: Int; @@ -42,7 +43,7 @@ export default abstract class AccountBase { * Sign message * @param message - Message to sign * @param options - Options - * @returns Signature as hex string of Uint8Array + * @returns Signature */ abstract signMessage( message: string, @@ -52,11 +53,27 @@ export default abstract class AccountBase { }, ): Promise; + /** + * Sign typed data + * @param type - Type of data to sign + * @param data - Encoded data to sign + * @param options - Options + * @returns Signature + */ + abstract signTypedData( + data: Encoded.ContractBytearray, + aci: AciValue, + options?: Domain & { + aeppOrigin?: string; + aeppRpcClientId?: string; + }, + ): Promise; + /** * Sign data blob * @param data - Data blob to sign * @param options - Options - * @returns Signed data blob + * @returns Signature */ abstract sign(data: string | Uint8Array, options?: any): Promise; diff --git a/src/account/Generalized.ts b/src/account/Generalized.ts index 622b21498b..002dfc8c0e 100644 --- a/src/account/Generalized.ts +++ b/src/account/Generalized.ts @@ -38,6 +38,11 @@ export default class AccountGeneralized extends AccountBase { throw new NotImplementedError('Can\'t sign using generalized account'); } + // eslint-disable-next-line class-methods-use-this + override async signTypedData(): Promise { + throw new NotImplementedError('Can\'t sign using generalized account'); + } + override async signTransaction( tx: Encoded.Transaction, { authData, onCompiler, onNode }: Parameters[1], diff --git a/src/account/Ledger.ts b/src/account/Ledger.ts index 4777e25686..ff95eb1591 100644 --- a/src/account/Ledger.ts +++ b/src/account/Ledger.ts @@ -40,6 +40,11 @@ export default class AccountLedger extends AccountBase { throw new NotImplementedError('RAW signing using Ledger HW'); } + // eslint-disable-next-line class-methods-use-this + override async signTypedData(): Promise { + throw new NotImplementedError('Typed data signing using Ledger HW'); + } + override async signTransaction( tx: Encoded.Transaction, { innerTx, networkId }: { innerTx?: boolean; networkId?: string } = {}, diff --git a/src/account/Memory.ts b/src/account/Memory.ts index 29bd5d260f..ec1a3da388 100644 --- a/src/account/Memory.ts +++ b/src/account/Memory.ts @@ -7,6 +7,7 @@ import { decode, encode, Encoded, Encoding, } from '../utils/encoder'; import { concatBuffers } from '../utils/other'; +import { hashTypedData, AciValue } from '../utils/typed-data'; import { buildTx } from '../tx/builder'; import { Tag } from '../tx/builder/constants'; @@ -74,4 +75,18 @@ export default class AccountMemory extends AccountBase { override async signMessage(message: string, options?: any): Promise { return this.sign(messageToHash(message), options); } + + override async signTypedData( + data: Encoded.ContractBytearray, + aci: AciValue, + { + name, version, networkId, contractAddress, ...options + }: Parameters[2] = {}, + ): Promise { + const dHash = hashTypedData(data, aci, { + name, version, networkId, contractAddress, + }); + const signature = await this.sign(dHash, options); + return encode(signature, Encoding.Signature); + } } diff --git a/src/account/Rpc.ts b/src/account/Rpc.ts index b4fbbaf6ed..196ac3fda0 100644 --- a/src/account/Rpc.ts +++ b/src/account/Rpc.ts @@ -28,9 +28,6 @@ export default class AccountRpc extends AccountBase { throw new NotImplementedError('RAW signing using wallet'); } - /** - * @returns Signed transaction - */ override async signTransaction( tx: Encoded.Transaction, { innerTx, networkId }: Parameters[1] = {}, @@ -49,12 +46,14 @@ export default class AccountRpc extends AccountBase { return res.signedTransaction; } - /** - * @returns Signed message - */ override async signMessage(message: string): Promise { const { signature } = await this._rpcClient .request(METHODS.signMessage, { onAccount: this.address, message }); return Buffer.from(signature, 'hex'); } + + // eslint-disable-next-line class-methods-use-this + override async signTypedData(): Promise { + throw new NotImplementedError('Typed data signing using wallet'); + } } diff --git a/src/index-browser.ts b/src/index-browser.ts index b7906d199a..c3ae0d7984 100644 --- a/src/index-browser.ts +++ b/src/index-browser.ts @@ -43,6 +43,9 @@ export { export { encode, decode, Encoding, Encoded, } from './utils/encoder'; +export { + encodeFateValue, decodeFateValue, hashTypedData, hashDomain, hashJson, +} from './utils/typed-data'; export { aensRevoke, aensUpdate, aensTransfer, aensQuery, aensClaim, aensPreclaim, aensBid, } from './aens'; diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 680dc1c213..29ac875a46 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -157,6 +157,7 @@ export function verify( return nacl.sign.detached.verify(data, signature, decode(address)); } +// TODO: consider rename to hashMessage export function messageToHash(message: string): Buffer { const p = Buffer.from('aeternity Signed Message:\n', 'utf8'); const msg = Buffer.from(message, 'utf8'); @@ -174,6 +175,8 @@ export function signMessage(message: string, privateKey: string | Buffer): Uint8 * @param address - Address to verify against * @returns is data was signed by address */ +// TODO: deprecate in favour of `verify(messageToHash(message), ...`, also the name is confusing +// it should contain "signature" export function verifyMessage( message: string, signature: Uint8Array, diff --git a/src/utils/typed-data.ts b/src/utils/typed-data.ts new file mode 100644 index 0000000000..31fc106de2 --- /dev/null +++ b/src/utils/typed-data.ts @@ -0,0 +1,90 @@ +// @ts-expect-error see https://github.com/aeternity/aepp-calldata-js/issues/216 +import ContractByteArrayEncoder from '@aeternity/aepp-calldata/src/ContractByteArrayEncoder'; +// @ts-expect-error see https://github.com/aeternity/aepp-calldata-js/issues/216 +import AciTypeResolver from '@aeternity/aepp-calldata/src/AciTypeResolver'; +import canonicalize from 'canonicalize'; +import { Encoded, decode } from './encoder'; +import { hash } from './crypto'; +import { concatBuffers } from './other'; + +/** + * Hashes arbitrary object, can be used to inline the aci hash to contract source code + */ +export function hashJson(data: unknown): Buffer { + return hash(canonicalize(data) ?? ''); +} + +// TODO: move this type to calldata library https://github.com/aeternity/aepp-calldata-js/issues/215 +// based on https://github.com/aeternity/aepp-calldata-js/blob/82b5a98f9b308482627da8d7484d213e9cf87151/src/AciTypeResolver.js#L129 +export type AciValue = 'void' | 'unit' | 'int' | 'bool' | 'string' | 'bits' | 'hash' | 'signature' +| 'address' | 'contract_pubkey' | 'Chain.ttl' | 'Chain.ga_meta_tx' | 'Chain.paying_for_tx' +| 'Chain.base_tx' | 'AENS.pointee' | 'AENS.name' | 'MCL_BLS12_381.fr' | 'MCL_BLS12_381.fp' +| { 'Set.set': readonly [AciValue] } +| { bytes: number } +| { list: readonly [AciValue] } +| { map: readonly [AciValue, AciValue] } +| { tuple: readonly AciValue[] } +| { record: ReadonlyArray<{ name: string; type: AciValue }> } +| { variant: ReadonlyArray<{ [key: string]: readonly AciValue[] }> } +| { option: readonly [AciValue] } +| { oracle: readonly [AciValue, AciValue] } +| { oracle_query: readonly [AciValue, AciValue] }; + +export interface Domain { + name?: string; + version?: number; + networkId?: string; + contractAddress?: Encoded.ContractAddress; +} + +// TODO: replace with api on calldata side https://github.com/aeternity/aepp-calldata-js/issues/216 +export function encodeFateValue( + value: unknown, + aci: AciValue, +): Encoded.ContractBytearray { + const contractByteArrayEncoder = new ContractByteArrayEncoder(); + const aciTypeResolver = new AciTypeResolver([]); + aciTypeResolver.isCustomType = () => false; + return contractByteArrayEncoder.encode(aciTypeResolver.resolveType(aci), value); +} + +// TODO: replace with api on calldata side https://github.com/aeternity/aepp-calldata-js/issues/216 +export function decodeFateValue( + value: Encoded.ContractBytearray, + aci: AciValue, +): Encoded.ContractBytearray { + const contractByteArrayEncoder = new ContractByteArrayEncoder(); + const aciTypeResolver = new AciTypeResolver([]); + aciTypeResolver.isCustomType = () => false; + return contractByteArrayEncoder.decodeWithType(value, aciTypeResolver.resolveType(aci)); +} + +/** + * Hashes domain object, can be used to inline domain hash to contract source code + */ +export function hashDomain(domain: Domain): Buffer { + const domainAci = { + record: [{ + name: 'name', + type: { option: ['string'] }, + }, { + name: 'version', + type: { option: ['int'] }, + }, { + name: 'networkId', + type: { option: ['string'] }, + }, { + name: 'contractAddress', + type: { option: ['contract_pubkey'] }, + }], + } as const; + return hash(decode(encodeFateValue(domain, domainAci))); +} + +export function hashTypedData( + data: Encoded.ContractBytearray, + aci: AciValue, + domain: Domain, +): Buffer { + return hash(concatBuffers([hashDomain(domain), hashJson(aci), hash(decode(data))])); +} diff --git a/test/integration/Middleware.ts b/test/integration/Middleware.ts index 415ef9de8a..2e0a83c87b 100644 --- a/test/integration/Middleware.ts +++ b/test/integration/Middleware.ts @@ -17,7 +17,7 @@ describe('MiddlewareSubscriber', () => { }, mdwGensPerMinute: res.mdwGensPerMinute, mdwHeight: res.mdwHeight, - mdwLastMigration: 20230519120000, + mdwLastMigration: res.mdwLastMigration, mdwRevision: res.mdwRevision, mdwSynced: true, mdwSyncing: true, diff --git a/test/integration/typed-data.ts b/test/integration/typed-data.ts new file mode 100644 index 0000000000..fe0804d5fb --- /dev/null +++ b/test/integration/typed-data.ts @@ -0,0 +1,159 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import canonicalize from 'canonicalize'; +import { + AeSdk, Contract, decode, Encoded, encodeFateValue, decodeFateValue, + hashDomain, hashJson, hashTypedData, +} from '../../src'; +import { Domain } from '../../src/utils/typed-data'; +import { getSdk } from '.'; + +describe('typed data', () => { + describe('hashJson', () => { + it('hashes json', () => { + expect(hashJson({ a: 'test', b: 42 }).toString('base64')).to.be.eql('EE0l7gg3Xv9K4szSHhK2g24mx5ck1MJHHVCLJscZyEA='); + }); + + it('gets the same hash if keys ordered differently', () => { + expect(hashJson({ b: 42, a: 'test' })).to.be.eql(hashJson({ a: 'test', b: 42 })); + }); + }); + + const plainAci = 'int'; + const plainDataDecoded = 42n; + const plainData = 'cb_VNLOFXc='; + + const recordAci = { + record: [ + { name: 'operation', type: 'string' }, + { name: 'parameter', type: 'int' }, + ], + } as const; + const recordDataDecoded = { + operation: 'test', + parameter: 42n, + }; + const recordData = 'cb_KxF0ZXN0VANAuWU='; + + describe('encodeFateValue', () => { + it('encodes plain value', () => { + expect(encodeFateValue(plainDataDecoded, plainAci)).to.be.equal(plainData); + }); + + it('encodes record value', () => { + expect(encodeFateValue(recordDataDecoded, recordAci)).to.be.equal(recordData); + }); + }); + + describe('decodeFateValue', () => { + it('decodes plain value', () => { + expect(decodeFateValue(plainData, plainAci)).to.be.eql(plainDataDecoded); + }); + + it('decodes record value', () => { + expect(decodeFateValue(recordData, recordAci)).to.be.eql(recordDataDecoded); + }); + }); + + const domain: Domain = { + name: 'Test app', + version: 2, + networkId: 'ae_devnet', + contractAddress: 'ct_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E', + }; + + describe('hashDomain', () => { + it('hashes', async () => { + expect(hashDomain(domain).toString('base64')).to.be.equal('h3MNXZ4vY96Ill3Q8tEFkX4hPEC2BO2uc6OkPSCYvCY='); + }); + }); + + describe('hashTypedData', () => { + it('hashes int', async () => { + const hash = hashTypedData(plainData, plainAci, domain); + expect(hash.toString('base64')).to.be.equal('dx3qpMQlMMijJAWfbecuoqsKDESjXZ0XoOfy7WIpRO0='); + }); + + it('hashes record', async () => { + const hash = hashTypedData(recordData, recordAci, domain); + expect(hash.toString('base64')).to.be.equal('WagBbUVTNJ+q/PUYUJbOno+pNM5Z1XNvdIZ4cLjiTwU='); + }); + }); + + describe('with contract', () => { + let aeSdk: AeSdk; + let contract: Contract<{ + getDomain: () => Domain & { version: bigint }; + getDomainHash: () => Uint8Array; + hashTypedData: (parameter: number) => Uint8Array; + verify: (parameter: number, pub: Encoded.AccountAddress, sig: Uint8Array) => boolean; + }>; + + before(async () => { + aeSdk = await getSdk(); + const typeJson = (canonicalize(recordAci) ?? '').replaceAll('"', '\\"'); + contract = await aeSdk.initializeContract({ + sourceCode: '' + + '\ninclude "String.aes"' + + '\ninclude "Option.aes"' + + '\n' + + '\ncontract VerifyTypedDataSignature =' + + '\n record domain = { name: option(string),' + + '\n version: option(int),' + + '\n networkId: option(string),' + + '\n contractAddress: option(VerifyTypedDataSignature) }' + + '\n' + + '\n entrypoint getDomain(): domain =' // kind of EIP-5267 + + '\n { name = Some("Test app"),' + + '\n version = Some(2),' + // TODO: don't hardcode network id after solving https://github.com/aeternity/aesophia/issues/461 + + '\n networkId = Some("ae_devnet"),' + + '\n contractAddress = Some(Address.to_contract(Contract.address)) }' + + '\n' + + '\n entrypoint getDomainHash() = Crypto.blake2b(getDomain())' + + '\n' + + '\n record typedData = { operation: string, parameter: int }' + + '\n' + + '\n entrypoint getTypedData(parameter: int): typedData =' + + '\n { operation = "test", parameter = parameter }' + + '\n' + + '\n entrypoint hashTypedData(parameter: int): hash =' + + `\n let typeHash = Crypto.blake2b("${typeJson}")` + + '\n let dataHash = Crypto.blake2b(getTypedData(parameter))' + + '\n Crypto.blake2b(Bytes.concat(getDomainHash(), Bytes.concat(typeHash, dataHash)))' + + '\n' + + '\n entrypoint verify(parameter: int, pub: address, sig: signature): bool =' + + '\n require(parameter > 40 && parameter < 50, "Invalid parameter")' + + '\n Crypto.verify_sig(hashTypedData(parameter), pub, sig)', + }); + await contract.$deploy([]); + domain.contractAddress = contract.$options.address; + }); + + it('gets domain', async () => { + expect((await contract.getDomain()).decodedResult).to.be.eql({ ...domain, version: 2n }); + }); + + it('calculates domain hash', async () => { + const domainHash = Buffer.from((await contract.getDomainHash()).decodedResult); + expect(domainHash).to.be.eql(hashDomain(domain)); + }); + + it('calculates typed data hash', async () => { + const data = encodeFateValue({ operation: 'test', parameter: 43 }, recordAci); + const typedDataHash = Buffer.from((await contract.hashTypedData(43)).decodedResult); + expect(typedDataHash).to.be.eql(hashTypedData(data, recordAci, domain)); + }); + + it('verifies signature', async () => { + const data = encodeFateValue({ operation: 'test', parameter: 45 }, recordAci); + const signature = await aeSdk.signTypedData(data, recordAci, domain); + const signatureDecoded = decode(signature); + + expect((await contract.verify(46, aeSdk.address, signatureDecoded)).decodedResult) + .to.be.equal(false); + expect((await contract.verify(45, aeSdk.address, signatureDecoded)).decodedResult) + .to.be.equal(true); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6a863e97e0..7b8551dbdd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,9 +10,9 @@ "isolatedModules": true, "outDir": "./es", "noImplicitOverride": true, - "module": "es2020", - "target": "es2020", - "lib": ["es2020", "dom"], + "module": "es2022", + "target": "es2022", + "lib": ["es2022", "dom"], "moduleResolution": "node", "preserveConstEnums": true, "declaration": true,