diff --git a/packages/snap/package.json b/packages/snap/package.json index a99690e..b16ba5c 100644 --- a/packages/snap/package.json +++ b/packages/snap/package.json @@ -30,6 +30,9 @@ "dependencies": { "@chainsafe/webzjs-keys": "0.1.0", "@metamask/snaps-sdk": "^6.17.1", + "@noble/hashes": "^1.4.0", + "@noble/secp256k1": "^2.1.0", + "bs58check": "^4.0.0", "buffer": "^6.0.3", "superstruct": "^2.0.2" }, diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index be58df2..e65e5da 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/ChainSafe/WebZjs.git" }, "source": { - "shasum": "wTPZl0jiwxyFr3cUhss5/b0WjFGdZP9y/VIPafGtDAM=", + "shasum": "gR+0fkxHpNURVXHOtwC42+XPZys6n8nDX6ZdyQ3bwpo=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -31,6 +31,20 @@ "coinType": 133 } ], + "snap_getBip32Entropy": [ + { + "path": ["m", "48'", "133'"], + "curve": "secp256k1" + }, + { + "path": ["m", "48'", "133'", "0'", "133000'", "0", "0"], + "curve": "secp256k1" + }, + { + "path": ["m", "48'", "1'", "0'", "133000'", "0", "0"], + "curve": "secp256k1" + } + ], "snap_manageState": {} }, "platformVersion": "6.17.1", diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index 08a5f6a..cbb80a2 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -8,11 +8,21 @@ import { } from '@metamask/snaps-sdk'; import { setBirthdayBlock } from './rpc/setBirthdayBlock'; import { getSnapState } from './rpc/getSnapState'; -import { SetBirthdayBlockParams, SignPcztParams, SnapState } from './types'; +import { + SetBirthdayBlockParams, + SignPcztParams, + SignTransparentMessageParams, + SignTransparentParams, + SnapState, + TransparentPublicKeyParams, +} from './types'; import { setSnapState } from './rpc/setSnapState'; -import { signPczt } from './rpc/signPczt' +import { signPczt } from './rpc/signPczt'; +import { signTransparent } from './rpc/signTransparent'; +import { getTransparentPublicKey } from './rpc/getTransparentPublicKey'; +import { signTransparentMessage } from './rpc/signTransparentMessage'; -import { assert, object, number, optional, string } from 'superstruct'; +import { assert, object, number, optional, string, array } from 'superstruct'; import { getSeedFingerprint } from './rpc/getSeedFingerprint'; import type { OnInstallHandler } from "@metamask/snaps-sdk"; import { installDialog } from './utils/dialogs'; @@ -45,6 +55,30 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request, origin }) => }), })); return await signPczt(request.params as SignPcztParams, origin); + case 'signTransparent': + assert(request.params, object({ + derivationPath: string(), + sighashes: array(string()), + details: object({ + toAddress: string(), + amount: string(), + network: string() + }) + })); + return await signTransparent(request.params as SignTransparentParams, origin); + case 'signTransparentMessage': + assert(request.params, object({ + derivationPath: string(), + message: string(), + network: optional(string()), + expectedAddress: optional(string()) + })); + return await signTransparentMessage(request.params as SignTransparentMessageParams, origin); + case 'getTransparentPublicKey': + assert(request.params, object({ + derivationPath: string() + })); + return await getTransparentPublicKey(request.params as TransparentPublicKeyParams, origin); case 'getSeedFingerprint': return await getSeedFingerprint(); case 'setBirthdayBlock': diff --git a/packages/snap/src/rpc/getTransparentPublicKey.tsx b/packages/snap/src/rpc/getTransparentPublicKey.tsx new file mode 100644 index 0000000..68fb1f8 --- /dev/null +++ b/packages/snap/src/rpc/getTransparentPublicKey.tsx @@ -0,0 +1,34 @@ +import { Box, Text } from '@metamask/snaps-sdk/jsx'; +import { ensureDerivationPath, requestPrivateKey, splitDerivationPath } from '../utils/derivation'; +import { getPublicKey } from '@noble/secp256k1'; +import { snapConfirm } from '../utils/dialogs'; + +export async function getTransparentPublicKey({ derivationPath }: { derivationPath: string }, origin: string): Promise { + ensureDerivationPath(derivationPath); + + const confirmed = await snapConfirm({ + title: 'Access to ZIP-48 public key', + prompt: ( + + Origin: {origin} + Derivation path: {derivationPath} + + ) + }); + + if (!confirmed) { + throw new Error('User rejected the request'); + } + + const pathSegments = splitDerivationPath(derivationPath); + const privateKey = await requestPrivateKey(pathSegments); + const publicKey = getPublicKey(privateKey, true); + return uint8ArrayToHex(publicKey); +} + +function uint8ArrayToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + diff --git a/packages/snap/src/rpc/signTransparent.tsx b/packages/snap/src/rpc/signTransparent.tsx new file mode 100644 index 0000000..b3626d0 --- /dev/null +++ b/packages/snap/src/rpc/signTransparent.tsx @@ -0,0 +1,116 @@ +import { Box, Divider, Heading, Text } from '@metamask/snaps-sdk/jsx'; +import { SignTransparentParams } from '../types'; +import { hexStringToUint8Array } from '../utils/hexStringToUint8Array'; +import { snapConfirm } from '../utils/dialogs'; +import { assert, array, object, optional, string } from 'superstruct'; +import { Signature, sign } from '@noble/secp256k1'; +import { ensureDerivationPath, requestPrivateKey, splitDerivationPath } from '../utils/derivation'; +import { ensureHmacSupport } from '../utils/secp256k1'; + +const SIGHASH_ALL = 0x01; + +const SignTransparentStruct = object({ + derivationPath: string(), + sighashes: array(string()), + details: object({ + toAddress: string(), + amount: string(), + network: string() + }), + metadata: optional( + object({ + redeemScript: optional(string()) + }) + ) +}); + +export async function signTransparent(params: SignTransparentParams, origin: string): Promise { + ensureHmacSupport(); + assert(params, SignTransparentStruct); + ensureDerivationPath(params.derivationPath); + + const confirmed = await snapConfirm({ + title: 'Transparent transaction signing', + prompt: ( + + Transparent Multisig + + Origin: {origin} + Recipient: {params.details.toAddress} + Amount: {params.details.amount} ZEC + Network: {params.details.network} + + ) + }); + + if (!confirmed) { + throw new Error('User rejected signing'); + } + + const derivationPathSegments = splitDerivationPath(params.derivationPath); + const privateKey = await requestPrivateKey(derivationPathSegments); + + const signatures = params.sighashes.map((hashHex) => { + if (typeof hashHex !== 'string' || hashHex.length !== 64) { + throw new Error('Invalid sighash'); + } + const digest = hexStringToUint8Array(hashHex); + const signature = sign(digest, privateKey); + const derSignature = signatureToDer(signature); + const fullSignature = new Uint8Array(derSignature.length + 1); + fullSignature.set(derSignature, 0); + fullSignature[derSignature.length] = SIGHASH_ALL; + return uint8ArrayToHex(fullSignature); + }); + + return signatures; +} + +function uint8ArrayToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function signatureToDer(signature: Signature): Uint8Array { + const r = normalizeDerComponent(signature.r); + const s = normalizeDerComponent(signature.s); + const length = 2 + r.length + 2 + s.length; + const result = new Uint8Array(2 + length); + let offset = 0; + result[offset++] = 0x30; + result[offset++] = length; + result[offset++] = 0x02; + result[offset++] = r.length; + result.set(r, offset); + offset += r.length; + result[offset++] = 0x02; + result[offset++] = s.length; + result.set(s, offset); + return result; +} + +function normalizeDerComponent(value: bigint): Uint8Array { + let hex = value.toString(16); + if (hex.length % 2 !== 0) { + hex = `0${hex}`; + } + const bytes = hexStringToUint8Array(hex); + let sliceIndex = 0; + while ( + sliceIndex < bytes.length - 1 && + bytes[sliceIndex] === 0x00 && + (bytes[sliceIndex + 1] & 0x80) === 0 + ) { + sliceIndex += 1; + } + const trimmed = bytes.slice(sliceIndex); + if ((trimmed[0] & 0x80) !== 0) { + const prefixed = new Uint8Array(trimmed.length + 1); + prefixed[0] = 0x00; + prefixed.set(trimmed, 1); + return prefixed; + } + return trimmed; +} + diff --git a/packages/snap/src/rpc/signTransparentMessage.tsx b/packages/snap/src/rpc/signTransparentMessage.tsx new file mode 100644 index 0000000..a3268d8 --- /dev/null +++ b/packages/snap/src/rpc/signTransparentMessage.tsx @@ -0,0 +1,175 @@ +import { Box, Divider, Heading, Text } from '@metamask/snaps-sdk/jsx'; +import { snapConfirm } from '../utils/dialogs'; +import { + SignTransparentMessageParams, + SignTransparentMessageResult, + TransparentNetwork, +} from '../types'; +import { ensureDerivationPath, requestPrivateKey, splitDerivationPath } from '../utils/derivation'; +import { ensureHmacSupport } from '../utils/secp256k1'; +import { getPublicKey, sign, verify } from '@noble/secp256k1'; +import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import bs58check from 'bs58check'; +import { Buffer } from 'buffer'; + +const MESSAGE_MAGIC = 'Zcash Signed Message:\n'; +const COMPRESSED_KEY_FLAG = 4; +const NETWORK_ORDER: TransparentNetwork[] = ['mainnet', 'testnet']; + +const P2PKH_PREFIX: Record = { + mainnet: 0x1cb8, + testnet: 0x1d25, +}; + +const textEncoder = new TextEncoder(); + +export async function signTransparentMessage( + params: SignTransparentMessageParams, + origin: string, +): Promise { + ensureHmacSupport(); + ensureDerivationPath(params.derivationPath); + + const message = params.message ?? ''; + if (message.length === 0) { + throw new Error('Message is empty; nothing to sign'); + } + + const network = normalizeNetwork(params.network); + + const confirmed = await snapConfirm({ + title: 'Sign message (transparent)', + prompt: ( + + Transparent Message Signing + + Origin: {origin} + Network: {network} + Derivation path: {params.derivationPath} + + Message: + {truncateMultiline(message, 180)} + + ), + }); + + if (!confirmed) { + throw new Error('User rejected message signing'); + } + + const derivationPathSegments = splitDerivationPath(params.derivationPath); + const privateKey = await requestPrivateKey(derivationPathSegments); + const digest = buildMessageDigest(message); + const signature = sign(digest, privateKey); + const compactSignature = signature.toCompactRawBytes(); + const recovery = signature.recovery ?? 0; + + const signatureWithHeader = new Uint8Array(65); + signatureWithHeader[0] = 27 + recovery + COMPRESSED_KEY_FLAG; + signatureWithHeader.set(compactSignature, 1); + + const publicKey = getPublicKey(privateKey, true); + const derivedAddress = deriveP2pkhAddress(publicKey, network); + console.log('derivedAddress SNAP', publicKey,derivedAddress); + const normalizedExpected = params.expectedAddress?.trim(); + if (normalizedExpected && normalizedExpected !== derivedAddress) { + throw new Error('Derived address does not match the provided expectedAddress'); + } + const onDeviceVerification = verify(signature, digest, publicKey); + console.log( + `[snap] Message signature verification for PK ${bytesToHex(publicKey)}: ${onDeviceVerification}`, + ); + + return { + signature: bytesToBase64(signatureWithHeader), + address: derivedAddress, + publicKey: bytesToHex(publicKey), + message, + derivationPath: params.derivationPath, + network, + }; +} + +function buildMessageDigest(message: string): Uint8Array { + const magicBytes = textEncoder.encode(MESSAGE_MAGIC); + const messageBytes = textEncoder.encode(message); + const payload = concatBytes([ + encodeCompactSize(magicBytes.length), + magicBytes, + encodeCompactSize(messageBytes.length), + messageBytes, + ]); + return sha256(sha256(payload)); +} + +function encodeCompactSize(value: number): Uint8Array { + if (value < 0xfd) { + return Uint8Array.of(value); + } + if (value <= 0xffff) { + return Uint8Array.of(0xfd, value & 0xff, (value >> 8) & 0xff); + } + if (value <= 0xffffffff) { + return Uint8Array.of( + 0xfe, + value & 0xff, + (value >> 8) & 0xff, + (value >> 16) & 0xff, + (value >> 24) & 0xff, + ); + } + const big = BigInt(value); + const result = new Uint8Array(9); + result[0] = 0xff; + for (let i = 0; i < 8; i += 1) { + result[i + 1] = Number((big >> BigInt(8 * i)) & 0xffn); + } + return result; +} + +function concatBytes(arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, current) => sum + current.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + arrays.forEach((array) => { + result.set(array, offset); + offset += array.length; + }); + return result; +} + +function deriveP2pkhAddress(publicKey: Uint8Array, network: TransparentNetwork): string { + const prefix = P2PKH_PREFIX[network]; + const hash = ripemd160(sha256(publicKey)); + const payload = Buffer.allocUnsafe(2 + hash.length); + payload.writeUInt16BE(prefix, 0); + Buffer.from(hash).copy(payload, 2); + return bs58check.encode(payload); +} + +function bytesToBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function truncateMultiline(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 3)}...`; +} + +function normalizeNetwork(network?: TransparentNetwork): TransparentNetwork { + if (network && NETWORK_ORDER.includes(network)) { + return network; + } + return 'testnet'; +} + + diff --git a/packages/snap/src/types.ts b/packages/snap/src/types.ts index 310d5ed..59d0fd7 100644 --- a/packages/snap/src/types.ts +++ b/packages/snap/src/types.ts @@ -10,6 +10,41 @@ export type SignPcztParams = { }; }; +export type SignTransparentParams = { + derivationPath: string; + sighashes: string[]; + details: { + toAddress: string; + amount: string; + network: string; + }; + metadata?: { + redeemScript?: string; + }; +}; + +export type TransparentPublicKeyParams = { + derivationPath: string; +}; + +export type TransparentNetwork = 'mainnet' | 'testnet'; + +export type SignTransparentMessageParams = { + derivationPath: string; + message: string; + network?: TransparentNetwork; + expectedAddress?: string; +}; + +export type SignTransparentMessageResult = { + signature: string; + address: string; + publicKey: string; + message: string; + derivationPath: string; + network: TransparentNetwork; +}; + export interface SnapState extends Record { webWalletSyncStartBlock: string; } diff --git a/packages/snap/src/utils/derivation.ts b/packages/snap/src/utils/derivation.ts new file mode 100644 index 0000000..902d8d7 --- /dev/null +++ b/packages/snap/src/utils/derivation.ts @@ -0,0 +1,28 @@ +import { hexStringToUint8Array } from './hexStringToUint8Array'; + +export function ensureDerivationPath(path: string): void { + if (!path || !path.startsWith('m/')) { + throw new Error('Derivation path must start with m/'); + } +} + +export function splitDerivationPath(path: string): string[] { + ensureDerivationPath(path); + return path.split('/').filter((segment) => segment.length > 0); +} + +export async function requestPrivateKey(path: string[]): Promise { + const entropyNode = (await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve: 'secp256k1', + }, + })) as { privateKey: string }; + + if (!entropyNode || typeof entropyNode.privateKey !== 'string') { + throw new Error('MetaMask did not return a private key for the specified path'); + } + + return hexStringToUint8Array(entropyNode.privateKey); +} diff --git a/packages/snap/src/utils/dialogs.tsx b/packages/snap/src/utils/dialogs.tsx index 6241ca5..a68072e 100644 --- a/packages/snap/src/utils/dialogs.tsx +++ b/packages/snap/src/utils/dialogs.tsx @@ -1,3 +1,4 @@ +import type { JSX } from '@metamask/snaps-sdk/jsx'; import { Box, Heading, Text, Link } from '@metamask/snaps-sdk/jsx'; export const installDialog = async () => { @@ -20,3 +21,26 @@ export const installDialog = async () => { }, }); }; + +export const snapConfirm = async ({ + title, + prompt +}: { + title: string; + prompt: JSX.Element; +}): Promise => { + const result = await snap.request({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: ( + + {title} + {prompt} + + ) + } + }); + + return Boolean(result); +}; diff --git a/packages/snap/src/utils/secp256k1.ts b/packages/snap/src/utils/secp256k1.ts new file mode 100644 index 0000000..b96e0ea --- /dev/null +++ b/packages/snap/src/utils/secp256k1.ts @@ -0,0 +1,26 @@ +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; +import { etc } from '@noble/secp256k1'; + +function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { + if (arrays.length === 1) { + return arrays[0]; + } + const totalLength = arrays.reduce((sum, current) => sum + current.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + arrays.forEach((array) => { + result.set(array, offset); + offset += array.length; + }); + return result; +} + +export function ensureHmacSupport(): void { + if (!etc.hmacSha256Sync) { + etc.hmacSha256Sync = (key: Uint8Array, ...msgs: Uint8Array[]) => { + const message = concatUint8Arrays(msgs); + return hmac(sha256, key, message); + }; + } +}