diff --git a/client-sdk/ts-web/package-lock.json b/client-sdk/ts-web/package-lock.json index 77c2294db8..fbfb33403d 100644 --- a/client-sdk/ts-web/package-lock.json +++ b/client-sdk/ts-web/package-lock.json @@ -2459,6 +2459,14 @@ "node": ">= 6" } }, + "node_modules/bsaes": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/bsaes/-/bsaes-0.0.2.tgz", + "integrity": "sha512-iVxJFMOvCUG85sX2UVpZ9IgvH6Jjc5xpd/W8pALvFE7zfCqHkV7hW3M2XZtpg9biPS0K4Eka96bbNNgLohcpgQ==", + "dependencies": { + "uint32": "^0.2.1" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3193,6 +3201,15 @@ "node": ">=0.4.0" } }, + "node_modules/deoxysii": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/deoxysii/-/deoxysii-0.0.2.tgz", + "integrity": "sha512-mMob/2wDZBatPC48g188hFt5xxrbfYMl9L+XQGOZuHPU4ScCHpAKkbZiAU1yg/kROj6nPKZp5eItuNI3LdU7vA==", + "dependencies": { + "bsaes": "0.0.2", + "uint32": "^0.2.1" + } + }, "node_modules/depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -8159,6 +8176,11 @@ "node": ">=4.2.0" } }, + "node_modules/uint32": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/uint32/-/uint32-0.2.1.tgz", + "integrity": "sha512-d3i8kc/4s1CFW5g3FctmF1Bu2GVXGBMTn82JY2BW0ZtTtI8pRx1YWGPCFBwRF4uYVSJ7ua4y+qYEPqS+x+3w7Q==" + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -8790,8 +8812,12 @@ "version": "0.2.1-alpha.1", "dependencies": { "@oasisprotocol/client": "^0.1.1-alpha.1", + "deoxysii": "^0.0.2", "elliptic": "^6.5.3", - "sha3": "^2.1.4" + "js-sha512": "^0.8.0", + "randombytes": "^2.0.1", + "sha3": "^2.1.4", + "tweetnacl": "^1.0.3" }, "devDependencies": { "@types/elliptic": "^6.4.14", @@ -9821,13 +9847,17 @@ "@types/jest": "^28.1.0", "buffer": "^6.0.3", "cypress": "^10.2.0", + "deoxysii": "^0.0.2", "elliptic": "^6.5.3", "jest": "^28.1.0", + "js-sha512": "^0.8.0", "prettier": "^2.7.1", "process": "^0.11.10", + "randombytes": "^2.0.1", "sha3": "^2.1.4", "stream-browserify": "^3.0.0", "ts-jest": "^28.0.5", + "tweetnacl": "^1.0.3", "typescript": "^4.7.4", "webpack": "^5.73.0", "webpack-cli": "^4.10.0", @@ -10907,6 +10937,14 @@ "fast-json-stable-stringify": "2.x" } }, + "bsaes": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/bsaes/-/bsaes-0.0.2.tgz", + "integrity": "sha512-iVxJFMOvCUG85sX2UVpZ9IgvH6Jjc5xpd/W8pALvFE7zfCqHkV7hW3M2XZtpg9biPS0K4Eka96bbNNgLohcpgQ==", + "requires": { + "uint32": "^0.2.1" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -11467,6 +11505,15 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "deoxysii": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/deoxysii/-/deoxysii-0.0.2.tgz", + "integrity": "sha512-mMob/2wDZBatPC48g188hFt5xxrbfYMl9L+XQGOZuHPU4ScCHpAKkbZiAU1yg/kROj6nPKZp5eItuNI3LdU7vA==", + "requires": { + "bsaes": "0.0.2", + "uint32": "^0.2.1" + } + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -15262,6 +15309,11 @@ "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true }, + "uint32": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/uint32/-/uint32-0.2.1.tgz", + "integrity": "sha512-d3i8kc/4s1CFW5g3FctmF1Bu2GVXGBMTn82JY2BW0ZtTtI8pRx1YWGPCFBwRF4uYVSJ7ua4y+qYEPqS+x+3w7Q==" + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/client-sdk/ts-web/rt/package.json b/client-sdk/ts-web/rt/package.json index 12e9d84608..3e71b80452 100644 --- a/client-sdk/ts-web/rt/package.json +++ b/client-sdk/ts-web/rt/package.json @@ -8,15 +8,19 @@ "scripts": { "prepare": "tsc", "check-playground": "cd playground && tsc -p jsconfig.json", - "fmt": "prettier --write playground/src src", - "lint": "prettier --check playground/src src", + "fmt": "prettier --write playground/src src test", + "lint": "prettier --check playground/src src test", "playground": "cd playground && webpack s -c webpack.config.js", "test": "jest" }, "dependencies": { "@oasisprotocol/client": "^0.1.1-alpha.1", + "deoxysii": "^0.0.2", "elliptic": "^6.5.3", - "sha3": "^2.1.4" + "js-sha512": "^0.8.0", + "randombytes": "^2.0.1", + "sha3": "^2.1.4", + "tweetnacl": "^1.0.3" }, "devDependencies": { "@types/elliptic": "^6.4.14", diff --git a/client-sdk/ts-web/rt/src/callformat.ts b/client-sdk/ts-web/rt/src/callformat.ts index 2ad2146202..61edf2d25a 100644 --- a/client-sdk/ts-web/rt/src/callformat.ts +++ b/client-sdk/ts-web/rt/src/callformat.ts @@ -1,4 +1,146 @@ +import * as oasis from '@oasisprotocol/client'; +// @ts-expect-error missing declaration +import * as deoxysii from 'deoxysii'; +import * as nacl from 'tweetnacl'; +// @ts-expect-error missing declaration +import * as randomBytes from 'randombytes'; + +import * as mraeDeoxysii from './mrae/deoxysii'; +import * as transaction from './transaction'; +import * as types from './types'; + /** * Call data key pair ID domain separation context base. */ export const CALL_DATA_KEY_PAIR_ID_CONTEXT_BASE = 'oasis-runtime-sdk/private: tx'; + +/** + * EncodeConfig is call encoding configuration. + * golang: oasis-sdk/client-sdk/go/callformat/callformat.go + * rust: + */ +export interface EncodeConfig { + /** + * publicKey is an optional runtime's call data public key to use for encrypted call formats. + */ + publicKey?: types.KeyManagerSignedPublicKey; +} + +export interface MetaEncryptedX25519DeoxysII { + sk: Uint8Array; + pk: Uint8Array; +} + +/** + * encodeCallWithNonceAndKeys encodes a call based on its configured call format. + * It returns the encoded call and any metadata needed to successfully decode the result. + */ +export async function encodeCallWithNonceAndKeys( + nonce: Uint8Array, + sk: Uint8Array, + pk: Uint8Array, + call: types.Call, + format: types.CallFormat, + config?: EncodeConfig, +): Promise<[types.Call, unknown]> { + switch (format) { + case transaction.CALLFORMAT_PLAIN: + return [call, undefined]; + case transaction.CALLFORMAT_ENCRYPTED_X25519DEOXYSII: + if (config?.publicKey === undefined) { + throw new Error('callformat: runtime call data public key not set'); + } + const rawCall = oasis.misc.toCBOR(call); + const zeroBuffer = new Uint8Array(0); + const sealedCall = mraeDeoxysii.boxSeal( + nonce, + rawCall, + zeroBuffer, + config.publicKey.key, + sk, + ); + const envelope: types.CallEnvelopeX25519DeoxysII = { + pk: pk, + nonce: nonce, + data: sealedCall, + }; + const encoded: types.Call = { + format: transaction.CALLFORMAT_ENCRYPTED_X25519DEOXYSII, + method: '', + body: oasis.misc.toCBOR(envelope), + }; + const meta: MetaEncryptedX25519DeoxysII = { + sk: sk, + pk: config.publicKey.key, + }; + return [encoded, meta]; + default: + throw new Error(`callformat: unsupported call format: ${format}`); + } +} + +/** + * encodeCall randomly generates nonce and keyPair and then call encodeCallWithNonceAndKeys + * It returns the encoded call and any metadata needed to successfully decode the result. + */ +export async function encodeCall( + call: types.Call, + format: types.CallFormat, + config?: EncodeConfig, +): Promise<[types.Call, unknown]> { + const nonce = randomBytes(deoxysii.NonceSize); + const keyPair = nacl.box.keyPair(); + return await encodeCallWithNonceAndKeys( + nonce, + keyPair.secretKey, + keyPair.publicKey, + call, + format, + config, + ); +} + +/** + * decodeResult performs result decoding based on the specified call format metadata. + */ +export async function decodeResult( + result: types.CallResult, + format: types.CallFormat, + meta?: MetaEncryptedX25519DeoxysII, +): Promise { + switch (format) { + case transaction.CALLFORMAT_PLAIN: + // In case of plain-text data format, we simply pass on the result unchanged. + return result; + case transaction.CALLFORMAT_ENCRYPTED_X25519DEOXYSII: + if (result.unknown) { + if (meta) { + const envelop = oasis.misc.fromCBOR( + result.unknown, + ) as types.ResultEnvelopeX25519DeoxysII; + const zeroBuffer = new Uint8Array(0); + const pt = mraeDeoxysii.boxOpen( + envelop?.nonce, + envelop?.data, + zeroBuffer, + meta.pk, + meta.sk, + ); + return oasis.misc.fromCBOR(pt) as types.CallResult; + } else { + throw new Error( + `callformat: MetaEncryptedX25519DeoxysII data is required for callformat: CALLFORMAT_ENCRYPTED_X25519DEOXYSII`, + ); + } + } else if (result.fail) { + throw new Error( + `callformat: failed call: module: ${result.fail.module} code: ${result.fail.code} message: ${result.fail.message}`, + ); + } + throw Object.assign(new Error(`callformat: unexpected result: ${result.ok}`), { + source: result, + }); + default: + throw new Error(`callformat: unsupported call format: ${format}`); + } +} diff --git a/client-sdk/ts-web/rt/src/index.ts b/client-sdk/ts-web/rt/src/index.ts index f71d58304a..0b5454fdd2 100644 --- a/client-sdk/ts-web/rt/src/index.ts +++ b/client-sdk/ts-web/rt/src/index.ts @@ -7,6 +7,7 @@ export * as contracts from './contracts'; export * as core from './core'; export * as event from './event'; export * as evm from './evm'; +export * as mraeDeoxysii from './mrae/deoxysii'; export * as rewards from './rewards'; export * as signatureSecp256k1 from './signature_secp256k1'; export * as token from './token'; diff --git a/client-sdk/ts-web/rt/src/mrae/deoxysii.ts b/client-sdk/ts-web/rt/src/mrae/deoxysii.ts new file mode 100644 index 0000000000..d408fbe38a --- /dev/null +++ b/client-sdk/ts-web/rt/src/mrae/deoxysii.ts @@ -0,0 +1,53 @@ +import * as oasis from '@oasisprotocol/client'; +// @ts-expect-error missing declaration +import * as deoxysii from 'deoxysii'; +import {sha512_256} from 'js-sha512'; +import * as nacl from 'tweetnacl'; + +const BOX_KDF_TWEAK = 'MRAE_Box_Deoxys-II-256-128'; + +/** + * deriveSymmetricKey derives a MRAE AEAD symmetric key suitable for use with the asymmetric + * box primitives from the provided X25519 public and private keys. + */ + +export function deriveSymmetricKey(publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array { + const pmk = nacl.scalarMult(privateKey, publicKey); + var kdf = sha512_256.hmac.create(BOX_KDF_TWEAK); + kdf.update(pmk); + return new Uint8Array(kdf.arrayBuffer()); +} + +/** + * boxSeal boxes ("seals") the provided additional data and plaintext via + * Deoxys-II-256-128 using a symmetric key derived from the provided + * X25519 public and private keys. + */ +export function boxSeal( + nonce: Uint8Array, + plainText: Uint8Array, + associateData: Uint8Array, + publicKey: Uint8Array, + privateKey: Uint8Array, +): Uint8Array { + const sharedKey = deriveSymmetricKey(publicKey, privateKey); + var aead = new deoxysii.AEAD(sharedKey); + return aead.encrypt(nonce, plainText, associateData); +} + +/** + * boxOpen unboxes ("opens") the provided additional data and plaintext via + * Deoxys-II-256-128 using a symmetric key derived from the provided + * X25519 public and private keys. + */ +export function boxOpen( + nonce: Uint8Array, + ciperText: Uint8Array, + associateData: Uint8Array, + publicKey: Uint8Array, + privateKey: Uint8Array, +): Uint8Array { + const sharedKey = deriveSymmetricKey(publicKey, privateKey); + var aead = new deoxysii.AEAD(sharedKey); + return aead.decrypt(nonce, ciperText, associateData); +} diff --git a/client-sdk/ts-web/rt/src/transaction.ts b/client-sdk/ts-web/rt/src/transaction.ts index 4c9926b065..02de9e25e4 100644 --- a/client-sdk/ts-web/rt/src/transaction.ts +++ b/client-sdk/ts-web/rt/src/transaction.ts @@ -17,6 +17,7 @@ export const LATEST_TRANSACTION_VERSION = 1; * Plain text call data. */ export const CALLFORMAT_PLAIN = 0; + /** * Encrypted call data using X25519 for key exchange and Deoxys-II for symmetric encryption. */ diff --git a/client-sdk/ts-web/rt/test/address.test.ts b/client-sdk/ts-web/rt/test/address.test.ts index 6e63a452e9..0361945223 100644 --- a/client-sdk/ts-web/rt/test/address.test.ts +++ b/client-sdk/ts-web/rt/test/address.test.ts @@ -5,7 +5,9 @@ describe('address', () => { it('Should derive the address correctly', async () => { const pk = Buffer.from('utrdHlX///////////////////////////////////8=', 'base64'); const address = await oasisRT.address.fromSigspec({ed25519: new Uint8Array(pk)}); - expect(oasisRT.address.toBech32(address)).toEqual('oasis1qryqqccycvckcxp453tflalujvlf78xymcdqw4vz'); + expect(oasisRT.address.toBech32(address)).toEqual( + 'oasis1qryqqccycvckcxp453tflalujvlf78xymcdqw4vz', + ); }); }); @@ -13,7 +15,9 @@ describe('address', () => { it('Should derive the address correctly', async () => { const pk = Buffer.from('Arra3R5V////////////////////////////////////', 'base64'); const address = await oasisRT.address.fromSigspec({secp256k1eth: new Uint8Array(pk)}); - expect(oasisRT.address.toBech32(address)).toEqual('oasis1qzd7akz24n6fxfhdhtk977s5857h3c6gf5583mcg'); + expect(oasisRT.address.toBech32(address)).toEqual( + 'oasis1qzd7akz24n6fxfhdhtk977s5857h3c6gf5583mcg', + ); }); }); }); diff --git a/client-sdk/ts-web/rt/test/callformat.test.ts b/client-sdk/ts-web/rt/test/callformat.test.ts new file mode 100644 index 0000000000..7ff086f131 --- /dev/null +++ b/client-sdk/ts-web/rt/test/callformat.test.ts @@ -0,0 +1,42 @@ +import * as oasisRT from './../src'; +import * as nacl from 'tweetnacl'; + +describe('callformat', () => { + describe('encodeCall/DecodeResult', () => { + it('Should encode and decode the message correctly', async () => { + const message = 'I will find some random message here'; + const pairs = nacl.box.keyPair(); + const publicKey = pairs.publicKey; + const rawCall: oasisRT.types.Call = { + format: oasisRT.transaction.CALLFORMAT_ENCRYPTED_X25519DEOXYSII, + method: '', + body: message, + }; + const dummy = new Uint8Array(); + const keyManagerPk: oasisRT.types.KeyManagerSignedPublicKey = { + key: publicKey, + checksum: dummy, + signature: dummy, + }; + const config: oasisRT.callformat.EncodeConfig = { + publicKey: keyManagerPk, + }; + const [sealedCall, meta] = await oasisRT.callformat.encodeCall( + rawCall, + oasisRT.transaction.CALLFORMAT_ENCRYPTED_X25519DEOXYSII, + config, + ); + + const fakedResult: oasisRT.types.CallResult = { + unknown: sealedCall.body as Uint8Array, + }; + + var decodedResult = (await oasisRT.callformat.decodeResult( + fakedResult, + oasisRT.transaction.CALLFORMAT_ENCRYPTED_X25519DEOXYSII, + meta as oasisRT.callformat.MetaEncryptedX25519DeoxysII, + )) as oasisRT.types.Call; + expect(decodedResult.body).toEqual(message); + }); + }); +}); diff --git a/client-sdk/ts-web/rt/test/mrae.test.ts b/client-sdk/ts-web/rt/test/mrae.test.ts new file mode 100644 index 0000000000..ce7af7701b --- /dev/null +++ b/client-sdk/ts-web/rt/test/mrae.test.ts @@ -0,0 +1,21 @@ +import * as oasisRT from './../src'; +import * as oasis from '@oasisprotocol/client'; +import * as nacl from 'tweetnacl'; + +describe('mrae', () => { + describe('symmetricKey', () => { + it('Should drive symmetric key correctly', () => { + const privateKeyHex = + 'c07b151fbc1e7a11dff926111188f8d872f62eba0396da97c0a24adb75161750'; + const privateKey = oasis.misc.fromHex(privateKeyHex); + const publicKey = nacl.scalarMult.base(privateKey); + expect(oasis.misc.toHex(publicKey)).toEqual( + '3046db3fa70ce605457dc47c48837ebd8bd0a26abfde5994d033e1ced68e2576', + ); + const sharedKey = oasisRT.mraeDeoxysii.deriveSymmetricKey(publicKey, privateKey); + expect(oasis.misc.toHex(sharedKey)).toEqual( + 'e69ac21066a8c2284e8fdc690e579af4513547b9b31dd144792c1904b45cf586', + ); + }); + }); +});