diff --git a/client-sdk/ts-web/core/docs/changelog.md b/client-sdk/ts-web/core/docs/changelog.md index 5caff8786a..c85c748201 100644 --- a/client-sdk/ts-web/core/docs/changelog.md +++ b/client-sdk/ts-web/core/docs/changelog.md @@ -1,5 +1,38 @@ # Changelog +## Unreleased changes + +Breaking changes: + +- `signature.NaclSigner` is moved out to a new + `@oasisprotocol/signer-tweetnacl` package. +- `hdkey.HDKey.getAccountSigner` now returns a `signature.Signer` instead of + a tweetnacl `SignKeyPair`. + To get the private key, use the new `hdkey.HDKey.seedFromMnemonic` and + `hdkey.HDKey.privateKeyFromSeed` functions. + +New features: + +- Hashing and many related functions that internally need to compute a hash, + such as getting the address of a public key, are now declared as + synchronous. + We had implementations that used synchronous hashing libraries all along, + but this is us giving up on eventually using the Web Crypto API for + SHA-512/256. +- For Ed25519 signing, there's a new `signature.WebCryptoSigner` taking the + place of `signature.NaclSigner`. + `await signature.WebCryptoSigner.generate(extractable)` is equivalent to + `signature.NaclSigner.fromRandom(note)`, and + `await signature.WebCryptoSigner.fromPrivateKey(priv)` is equivalent to + `signature.NaclSigner.fromSeed(priv, note)`. + +Little things: + +- Removed dependency on tweetnacl. +- We're switching lots of cryptography dependencies to noble cryptography + libraries. +- Ed25519 verification now uses the Web Crypto API. + ## v1.1.0 Spotlight change: diff --git a/client-sdk/ts-web/core/package.json b/client-sdk/ts-web/core/package.json index 5e013d1248..af7928566d 100644 --- a/client-sdk/ts-web/core/package.json +++ b/client-sdk/ts-web/core/package.json @@ -30,8 +30,7 @@ "bip39": "^3.1.0", "cborg": "^2.0.3", "grpc-web": "^1.5.0", - "protobufjs": "~7.4.0", - "tweetnacl": "^1.0.3" + "protobufjs": "~7.4.0" }, "devDependencies": { "@types/jest": "^29.5.13", diff --git a/client-sdk/ts-web/core/playground/src/startPlayground.mjs b/client-sdk/ts-web/core/playground/src/startPlayground.mjs index 3402b995a4..f6e8b8405d 100644 --- a/client-sdk/ts-web/core/playground/src/startPlayground.mjs +++ b/client-sdk/ts-web/core/playground/src/startPlayground.mjs @@ -35,27 +35,27 @@ export async function startPlayground() { // Try sending a transaction. { - const src = oasis.signature.NaclSigner.fromRandom('this key is not important'); - const dst = oasis.signature.NaclSigner.fromRandom('this key is not important'); + const src = await oasis.signature.WebCryptoSigner.generate(false); + const dst = await oasis.signature.WebCryptoSigner.generate(false); console.log('src', src, 'dst', dst); const chainContext = await nic.consensusGetChainContext(); console.log('chain context', chainContext); const genesis = await nic.consensusGetGenesisDocument(); - const ourChainContext = await oasis.genesis.chainContext(genesis); + const ourChainContext = oasis.genesis.chainContext(genesis); console.log('computed from genesis', ourChainContext); if (ourChainContext !== chainContext) throw new Error('computed chain context mismatch'); const nonce = await nic.consensusGetSignerNonce({ - account_address: await oasis.staking.addressFromPublicKey(src.public()), + account_address: oasis.staking.addressFromPublicKey(src.public()), height: oasis.consensus.HEIGHT_LATEST, }); console.log('nonce', nonce); const account = await nic.stakingAccount({ height: oasis.consensus.HEIGHT_LATEST, - owner: await oasis.staking.addressFromPublicKey(src.public()), + owner: oasis.staking.addressFromPublicKey(src.public()), }); console.log('account', account); if ((account.general?.nonce ?? 0) !== nonce) throw new Error('nonce mismatch'); @@ -64,7 +64,7 @@ export async function startPlayground() { tw.setNonce(account.general?.nonce ?? 0); tw.setFeeAmount(oasis.quantity.fromBigInt(0n)); tw.setBody({ - to: await oasis.staking.addressFromPublicKey(dst.public()), + to: oasis.staking.addressFromPublicKey(dst.public()), amount: oasis.quantity.fromBigInt(0n), }); @@ -75,7 +75,7 @@ export async function startPlayground() { await tw.sign(new oasis.signature.BlindContextSigner(src), chainContext); console.log('singed transaction', tw.signedTransaction); - console.log('hash', await tw.hash()); + console.log('hash', tw.hash()); await tw.submit(nic); console.log('sent'); @@ -100,9 +100,9 @@ export async function startPlayground() { signedTransaction, ); console.log({ - hash: await oasis.consensus.hashSignedTransaction(signedTransaction), + hash: oasis.consensus.hashSignedTransaction(signedTransaction), from: oasis.staking.addressToBech32( - await oasis.staking.addressFromPublicKey( + oasis.staking.addressFromPublicKey( signedTransaction.signature.public_key, ), ), diff --git a/client-sdk/ts-web/core/src/common.ts b/client-sdk/ts-web/core/src/common.ts index ee817e48ca..cd65025b2b 100644 --- a/client-sdk/ts-web/core/src/common.ts +++ b/client-sdk/ts-web/core/src/common.ts @@ -72,8 +72,8 @@ export const IDENTITY_MODULE_NAME = 'identity'; */ export const IDENTITY_ERR_CERTIFICATE_ROTATION_FORBIDDEN_CODE = 1; -export function openSignedEntity(context: string, signed: types.SignatureSigned) { - return misc.fromCBOR(signature.openSigned(context, signed)) as types.Entity; +export async function openSignedEntity(context: string, signed: types.SignatureSigned) { + return misc.fromCBOR(await signature.openSigned(context, signed)) as types.Entity; } export async function signSignedEntity( @@ -84,8 +84,11 @@ export async function signSignedEntity( return await signature.signSigned(signer, context, misc.toCBOR(entity)); } -export function openMultiSignedNode(context: string, multiSigned: types.SignatureMultiSigned) { - return misc.fromCBOR(signature.openMultiSigned(context, multiSigned)) as types.Node; +export async function openMultiSignedNode( + context: string, + multiSigned: types.SignatureMultiSigned, +) { + return misc.fromCBOR(await signature.openMultiSigned(context, multiSigned)) as types.Node; } export async function signMultiSignedNode( diff --git a/client-sdk/ts-web/core/src/consensus.ts b/client-sdk/ts-web/core/src/consensus.ts index da3861e568..f014216aba 100644 --- a/client-sdk/ts-web/core/src/consensus.ts +++ b/client-sdk/ts-web/core/src/consensus.ts @@ -91,9 +91,9 @@ export const TRANSACTION_ERR_GAS_PRICE_TOO_LOW_CODE = 3; */ export const TRANSACTION_ERR_UPGRADE_PENDING = 4; -export function openSignedTransaction(chainContext: string, signed: types.SignatureSigned) { +export async function openSignedTransaction(chainContext: string, signed: types.SignatureSigned) { const context = signature.combineChainContext(TRANSACTION_SIGNATURE_CONTEXT, chainContext); - return misc.fromCBOR(signature.openSigned(context, signed)) as types.ConsensusTransaction; + return misc.fromCBOR(await signature.openSigned(context, signed)) as types.ConsensusTransaction; } export async function signSignedTransaction( diff --git a/client-sdk/ts-web/core/src/hdkey.ts b/client-sdk/ts-web/core/src/hdkey.ts index 0e37c5e0c0..82d51b70c9 100644 --- a/client-sdk/ts-web/core/src/hdkey.ts +++ b/client-sdk/ts-web/core/src/hdkey.ts @@ -1,8 +1,8 @@ import {hmac} from '@noble/hashes/hmac'; import {sha512} from '@noble/hashes/sha512'; -import {SignKeyPair, sign} from 'tweetnacl'; -import {generateMnemonic, mnemonicToSeed, validateMnemonic} from 'bip39'; +import {generateMnemonic, mnemonicToSeed} from 'bip39'; import {concat} from './misc'; +import {Signer, WebCryptoSigner} from './signature'; const ED25519_CURVE = 'ed25519 seed'; const HARDENED_OFFSET = 0x80000000; @@ -13,27 +13,54 @@ const pathRegex = new RegExp("^m(\\/[0-9]+')+$"); * https://github.com/oasisprotocol/adrs/blob/main/0008-standard-account-key-generation.md */ export class HDKey { - public readonly keypair: SignKeyPair; + private static ensureValidIndex(index: number) { + if (index < 0 || index > 0x7fffffff) { + throw new Error('Account number must be >= 0 and <= 2147483647'); + } + } /** - * Generates the keypair matching the supplied parameters - * @param mnemonic BIP-0039 Mnemonic + * Generates the seed matching the supplied parameters + * @param mnemonic BIP-0039 mnemonic + * @param passphrase Optional BIP-0039 passphrase + * @returns BIP-0039 seed + */ + public static async seedFromMnemonic(mnemonic: string, passphrase?: string) { + return new Uint8Array(await mnemonicToSeed(mnemonic, passphrase)); + } + + /** + * Generates the signer matching the supplied parameters + * @param seed BIP-0039 seed + * @param index Account index + * @returns ed25519 private key for these parameters + */ + public static privateKeyFromSeed(seed: Uint8Array, index: number = 0) { + HDKey.ensureValidIndex(index); + + const key = HDKey.makeHDKey(ED25519_CURVE, seed); + return key.derivePath(`m/44'/474'/${index}'`).privateKey; + } + + /** + * Generates the Signer matching the supplied parameters + * @param mnemonic BIP-0039 mnemonic * @param index Account index * @param passphrase Optional BIP-0039 passphrase - * @returns SignKeyPair for these parameters + * @returns Signer for these parameters */ public static async getAccountSigner( mnemonic: string, index: number = 0, passphrase?: string, - ): Promise { - if (index < 0 || index > 0x7fffffff) { - throw new Error('Account number must be >= 0 and <= 2147483647'); - } + ): Promise { + // privateKeyFromSeed checks too, but validate before the expensive + // seedFromMnemonic call. + HDKey.ensureValidIndex(index); - const seed = await mnemonicToSeed(mnemonic, passphrase); - const key = HDKey.makeHDKey(ED25519_CURVE, seed); - return key.derivePath(`m/44'/474'/${index}'`).keypair; + const seed = await HDKey.seedFromMnemonic(mnemonic, passphrase); + const privateKey = HDKey.privateKeyFromSeed(seed, index); + return await WebCryptoSigner.fromPrivateKey(privateKey); } /** @@ -48,9 +75,7 @@ export class HDKey { private constructor( private readonly privateKey: Uint8Array, private readonly chainCode: Uint8Array, - ) { - this.keypair = sign.keyPair.fromSeed(privateKey); - } + ) {} /** * Returns the HDKey for the given derivation path diff --git a/client-sdk/ts-web/core/src/misc.ts b/client-sdk/ts-web/core/src/misc.ts index 1a98d43aa5..a72a8fc45e 100644 --- a/client-sdk/ts-web/core/src/misc.ts +++ b/client-sdk/ts-web/core/src/misc.ts @@ -82,6 +82,12 @@ export function fromBase64(base64: string) { return u8; } +export function fromBase64url(base64url: string) { + const padding = ['', '', '==', '='][base64url.length % 4]; + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + padding; + return fromBase64(base64); +} + export function toStringUTF8(u8: Uint8Array) { return new TextDecoder().decode(u8); } diff --git a/client-sdk/ts-web/core/src/signature.ts b/client-sdk/ts-web/core/src/signature.ts index c05a89b032..215548f331 100644 --- a/client-sdk/ts-web/core/src/signature.ts +++ b/client-sdk/ts-web/core/src/signature.ts @@ -1,5 +1,3 @@ -import * as nacl from 'tweetnacl'; - import * as hash from './hash'; import * as misc from './misc'; import * as types from './types'; @@ -24,20 +22,29 @@ export interface ContextSigner { sign(context: string, message: Uint8Array): Promise; } -export function verify( +async function verifyPrepared( + publicKey: Uint8Array, + signerMessage: Uint8Array, + signature: Uint8Array, +) { + const publicCK = await crypto.subtle.importKey('raw', publicKey, {name: 'Ed25519'}, true, ['verify']); + return await crypto.subtle.verify({name: 'Ed25519'}, publicCK, signature, signerMessage); +} + +export async function verify( publicKey: Uint8Array, context: string, message: Uint8Array, signature: Uint8Array, ) { const signerMessage = prepareSignerMessage(context, message); - const sigOk = nacl.sign.detached.verify(signerMessage, signature, publicKey); + const sigOk = await verifyPrepared(publicKey, signerMessage, signature); return sigOk; } -export function openSigned(context: string, signed: types.SignatureSigned) { - const sigOk = verify( +export async function openSigned(context: string, signed: types.SignatureSigned) { + const sigOk = await verify( signed.signature.public_key, context, signed.untrusted_raw_value, @@ -57,13 +64,13 @@ export async function signSigned(signer: ContextSigner, context: string, rawValu } as types.SignatureSigned; } -export function openMultiSigned(context: string, multiSigned: types.SignatureMultiSigned) { +export async function openMultiSigned(context: string, multiSigned: types.SignatureMultiSigned) { const signerMessage = prepareSignerMessage(context, multiSigned.untrusted_raw_value); for (const signature of multiSigned.signatures) { - const sigOk = nacl.sign.detached.verify( + const sigOk = await verifyPrepared( + signature.public_key, signerMessage, signature.signature, - signature.public_key, ); if (!sigOk) throw new Error('signature verification failed'); } @@ -105,65 +112,83 @@ export class BlindContextSigner implements ContextSigner { } } -/** - * An in-memory signer based on tweetnacl. We've included this for development. - */ -export class NaclSigner implements Signer { - key: nacl.SignKeyPair; +export class WebCryptoSigner implements Signer { + privateCK: CryptoKey; + publicKey: Uint8Array; - constructor(key: nacl.SignKeyPair, note: string) { - if (note !== 'this key is not important') throw new Error('insecure signer implementation'); - this.key = key; + constructor(privateCK: CryptoKey, publicKey: Uint8Array) { + this.privateCK = privateCK; + this.publicKey = publicKey; } /** - * Generate a keypair from a random seed - * @param note Set to 'this key is not important' to acknowledge the risks - * @returns Instance of NaclSigner + * Create a CryptoKeyPair from a 32-byte private key. */ - static fromRandom(note: string) { - const secret = new Uint8Array(32); - crypto.getRandomValues(secret); - return NaclSigner.fromSeed(secret, note); + static async keyPairFromPrivateKey(privateKey: Uint8Array) { + const privateDER = misc.concat( + new Uint8Array([ + // PrivateKeyInfo + 0x30, 0x2e, + // version 0 + 0x02, 0x01, 0x00, + // privateKeyAlgorithm 1.3.101.112 + 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, + // privateKey + 0x04, 0x22, 0x04, 0x20, + ]), + privateKey, + ); + const privateCK = await crypto.subtle.importKey('pkcs8', privateDER, {name: 'Ed25519'}, true, ['sign']); + const privateJWK = await crypto.subtle.exportKey('jwk', privateCK); + const publicJWK = { + kty: privateJWK.kty, + crv: privateJWK.crv, + x: privateJWK.x, + } as JsonWebKey; + const publicCK = await crypto.subtle.importKey('jwk', publicJWK, {name: 'Ed25519'}, true, ['verify']); + return { + publicKey: publicCK, + privateKey: privateCK, + } as CryptoKeyPair; } /** - * Instanciate from a given secret - * @param secret 64 bytes ed25519 secret (h) that will be used to sign messages - * @param note Set to 'this key is not important' to acknowledge the risks - * @returns Instance of NaclSigner + * Get the public key from a CryptoKeyPair. */ - static fromSecret(secret: Uint8Array, note: string) { - const key = nacl.sign.keyPair.fromSecretKey(secret); - return new NaclSigner(key, note); + static async publicKeyFromKeyPair(keyPair: CryptoKeyPair) { + return new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey)); } /** - * Instanciate from a given seed - * @param seed 32 bytes ed25519 seed (k) that will deterministically generate a private key - * @param note Set to 'this key is not important' to acknowledge the risks - * @returns Instance of NaclSigner + * Create an instance with a newly generated key. */ - static fromSeed(seed: Uint8Array, note: string) { - const key = nacl.sign.keyPair.fromSeed(seed); - return new NaclSigner(key, note); + static async generate(extractable: boolean) { + const keyPair = await crypto.subtle.generateKey({name: 'Ed25519'}, extractable, ['sign', 'verify']) as CryptoKeyPair; + return await WebCryptoSigner.fromKeyPair(keyPair); } /** - * Returns the 32 bytes public key of this key pair - * @returns Public key + * Create an instance from a CryptoKeyPair. */ - public(): Uint8Array { - return this.key.publicKey; + static async fromKeyPair(keyPair: CryptoKeyPair) { + const publicKey = await WebCryptoSigner.publicKeyFromKeyPair(keyPair); + return new WebCryptoSigner(keyPair.privateKey, publicKey); } /** - * Signs the given message - * @param message Bytes to sign - * @returns Signed message + * Create an instance from a 32-byte private key. */ + static async fromPrivateKey(privateKey: Uint8Array) { + const keyPair = await WebCryptoSigner.keyPairFromPrivateKey(privateKey); + return await WebCryptoSigner.fromKeyPair(keyPair); + } + + public(): Uint8Array { + return this.publicKey; + } + async sign(message: Uint8Array): Promise { - return nacl.sign.detached(message, this.key.secretKey); + return new Uint8Array(await crypto.subtle.sign({name: 'Ed25519'}, this.privateCK, message)); } } diff --git a/client-sdk/ts-web/core/test/hdkey.test.ts b/client-sdk/ts-web/core/test/hdkey.test.ts index d1614f8b89..d3f63ffb36 100644 --- a/client-sdk/ts-web/core/test/hdkey.test.ts +++ b/client-sdk/ts-web/core/test/hdkey.test.ts @@ -1,6 +1,16 @@ +import {webcrypto} from 'crypto'; + import {HDKey} from '../src/hdkey'; +import {concat, toHex} from '../src/misc'; +import {WebCryptoSigner} from '../src/signature'; + import * as adr0008VectorsRaw from './adr-0008-vectors.json'; +if (typeof crypto === 'undefined') { + // @ts-expect-error there are some inconsequential type differences + globalThis.crypto = webcrypto; +} + interface Adr0008Vector { kind: string; bip39_mnemonic: string; @@ -16,8 +26,6 @@ interface Adr0008Vector { const adr0008Vectors: Adr0008Vector[] = adr0008VectorsRaw; -const uint2hex = (array: Uint8Array) => Buffer.from(array).toString('hex'); - describe('HDKey', () => { describe('getAccountSigner', () => { it('Should reject negative account numbers', async () => { @@ -41,17 +49,16 @@ describe('HDKey', () => { ? vector.bip39_passphrase : undefined; + const seed = await HDKey.seedFromMnemonic(vector.bip39_mnemonic, passphrase); for (let account of vector.oasis_accounts) { expect(account.bip32_path).toMatch(/^m\/44'\/474'\/[0-9]+'/); const index = Number(account.bip32_path.split('/').pop()!.replace("'", '')); - const keyPair = await HDKey.getAccountSigner( - vector.bip39_mnemonic, - index, - passphrase, - ); - - expect(uint2hex(keyPair.secretKey)).toEqual(account.private_key); - expect(uint2hex(keyPair.publicKey)).toEqual(account.public_key); + const privateKey = HDKey.privateKeyFromSeed(seed, index); + const signer = await WebCryptoSigner.fromPrivateKey(privateKey); + + const publicKey = signer.public(); + expect(toHex(concat(privateKey, publicKey))).toEqual(account.private_key); + expect(toHex(publicKey)).toEqual(account.public_key); } }); }); diff --git a/client-sdk/ts-web/ext-utils/sample-ext/src/index.js b/client-sdk/ts-web/ext-utils/sample-ext/src/index.js index a847031e80..b117459826 100644 --- a/client-sdk/ts-web/ext-utils/sample-ext/src/index.js +++ b/client-sdk/ts-web/ext-utils/sample-ext/src/index.js @@ -152,8 +152,7 @@ function getSigner() { ); } } - const pair = await oasis.hdkey.HDKey.getAccountSigner(mnemonic); - const rawSigner = new oasis.signature.NaclSigner(pair, 'this key is not important'); + const rawSigner = await oasis.hdkey.HDKey.getAccountSigner(mnemonic); return new oasis.signature.BlindContextSigner(rawSigner); })(); } diff --git a/client-sdk/ts-web/ext-utils/sample-page/src/index.js b/client-sdk/ts-web/ext-utils/sample-page/src/index.js index 0d9d0d52c1..a7daedb324 100644 --- a/client-sdk/ts-web/ext-utils/sample-page/src/index.js +++ b/client-sdk/ts-web/ext-utils/sample-page/src/index.js @@ -44,17 +44,17 @@ export const playground = (async function () { console.log('public key base64', oasis.misc.toBase64(publicKey)); console.log( 'address bech32', - oasis.staking.addressToBech32(await oasis.staking.addressFromPublicKey(publicKey)), + oasis.staking.addressToBech32(oasis.staking.addressFromPublicKey(publicKey)), ); - const dst = oasis.signature.NaclSigner.fromRandom('this key is not important'); + const dst = await oasis.signature.WebCryptoSigner.generate(false); const tw = oasis.staking .transferWrapper() .setNonce(101n) .setFeeAmount(oasis.quantity.fromBigInt(102n)) .setFeeGas(103n) .setBody({ - to: await oasis.staking.addressFromPublicKey(dst.public()), + to: oasis.staking.addressFromPublicKey(dst.public()), amount: oasis.quantity.fromBigInt(104n), }); console.log('requesting signature'); @@ -65,7 +65,7 @@ export const playground = (async function () { const rtw = new oasisRT.accounts.Wrapper(oasis.misc.fromString('fake-runtime-id-for-testing')) .callTransfer() .setBody({ - to: await oasis.staking.addressFromPublicKey(dst.public()), + to: oasis.staking.addressFromPublicKey(dst.public()), amount: [oasis.quantity.fromBigInt(105n), oasis.misc.fromString('TEST')], }) .setSignerInfo([ diff --git a/client-sdk/ts-web/package-lock.json b/client-sdk/ts-web/package-lock.json index 5f856631aa..07916a8b09 100644 --- a/client-sdk/ts-web/package-lock.json +++ b/client-sdk/ts-web/package-lock.json @@ -8,7 +8,8 @@ "core", "ext-utils", "rt", - "signer-ledger" + "signer-ledger", + "signer-tweetnacl" ] }, "core": { @@ -21,8 +22,7 @@ "bip39": "^3.1.0", "cborg": "^2.0.3", "grpc-web": "^1.5.0", - "protobufjs": "~7.4.0", - "tweetnacl": "^1.0.3" + "protobufjs": "~7.4.0" }, "devDependencies": { "@types/jest": "^29.5.13", @@ -1314,6 +1314,10 @@ "@ledgerhq/hw-transport": "^6.1.0" } }, + "node_modules/@oasisprotocol/signer-tweetnacl": { + "resolved": "signer-tweetnacl", + "link": true + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -9486,6 +9490,20 @@ "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.1.0" } + }, + "signer-tweetnacl": { + "name": "@oasisprotocol/signer-tweetnacl", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@oasisprotocol/client": "^1.1.0", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "prettier": "^3.3.3", + "typedoc": "^0.26.7", + "typescript": "^5.6.2" + } } }, "dependencies": { @@ -10481,7 +10499,6 @@ "protobufjs-cli": "^1.1.3", "stream-browserify": "^3.0.0", "ts-jest": "^29.2.5", - "tweetnacl": "^1.0.3", "typedoc": "^0.26.7", "typescript": "^5.6.2", "webpack": "^5.95.0", @@ -10562,6 +10579,16 @@ "@ledgerhq/hw-transport": "^6.1.0" } }, + "@oasisprotocol/signer-tweetnacl": { + "version": "file:signer-tweetnacl", + "requires": { + "@oasisprotocol/client": "^1.1.0", + "prettier": "^3.3.3", + "tweetnacl": "^1.0.3", + "typedoc": "^0.26.7", + "typescript": "^5.6.2" + } + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", diff --git a/client-sdk/ts-web/package.json b/client-sdk/ts-web/package.json index cbc6046d22..fb9e15a1d3 100644 --- a/client-sdk/ts-web/package.json +++ b/client-sdk/ts-web/package.json @@ -3,6 +3,7 @@ "core", "ext-utils", "rt", - "signer-ledger" + "signer-ledger", + "signer-tweetnacl" ] } diff --git a/client-sdk/ts-web/rt/docs/changelog.md b/client-sdk/ts-web/rt/docs/changelog.md index e8c80af10e..671026de0f 100644 --- a/client-sdk/ts-web/rt/docs/changelog.md +++ b/client-sdk/ts-web/rt/docs/changelog.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased changes + +New features: + +- Functions that internally need to compute a hash, such as + `address.fromSigspec`, are declared as synchronous now. +- secp256k1 verification is declared as synchronous now. + +Little things: + +- We're switching lots of cryptography dependencies to noble cryptography + libraries. + ## v1.1.0 Spotlight change: diff --git a/client-sdk/ts-web/rt/playground/src/consensus.js b/client-sdk/ts-web/rt/playground/src/consensus.js index c6b7657e8b..4303183b4a 100644 --- a/client-sdk/ts-web/rt/playground/src/consensus.js +++ b/client-sdk/ts-web/rt/playground/src/consensus.js @@ -119,9 +119,8 @@ export const playground = (async function () { })(); }); - const alice = oasis.signature.NaclSigner.fromSeed( + const alice = await oasis.signature.WebCryptoSigner.fromPrivateKey( oasis.hash.hash(oasis.misc.fromString('oasis-runtime-sdk/test-keys: alice')), - 'this key is not important', ); const csAlice = new oasis.signature.BlindContextSigner(alice); const aliceAddr = oasis.staking.addressFromPublicKey(alice.public()); diff --git a/client-sdk/ts-web/rt/playground/src/index.js b/client-sdk/ts-web/rt/playground/src/index.js index 86ace4c082..3a01d2220c 100644 --- a/client-sdk/ts-web/rt/playground/src/index.js +++ b/client-sdk/ts-web/rt/playground/src/index.js @@ -111,7 +111,7 @@ export const playground = (async function () { console.log('signature', signature); console.log( 'valid', - await oasisRT.signatureSecp256k1.verify('test context', message, signature, publicKey), + oasisRT.signatureSecp256k1.verify('test context', message, signature, publicKey), ); } @@ -120,7 +120,7 @@ export const playground = (async function () { const runtimeID = oasis.misc.fromHex( '8000000000000000000000000000000000000000000000000000000000000000', ); - const chainContext = await oasisRT.transaction.deriveChainContext( + const chainContext = oasisRT.transaction.deriveChainContext( runtimeID, '643fb06848be7e970af3b5b2d772eb8cfb30499c8162bc18ac03df2f5e22520e', ); @@ -145,14 +145,12 @@ export const playground = (async function () { console.log(`ready ${waitEnd2 - waitStart2} ms`); } - const alice = oasis.signature.NaclSigner.fromSeed( - await oasis.hash.hash(oasis.misc.fromString('oasis-runtime-sdk/test-keys: alice')), - 'this key is not important', + const alice = await oasis.signature.WebCryptoSigner.fromPrivateKey( + oasis.hash.hash(oasis.misc.fromString('oasis-runtime-sdk/test-keys: alice')), ); const csAlice = new oasis.signature.BlindContextSigner(alice); - const bob = oasis.signature.NaclSigner.fromSeed( - await oasis.hash.hash(oasis.misc.fromString('oasis-runtime-sdk/test-keys: bob')), - 'this key is not important', + const bob = await oasis.signature.WebCryptoSigner.fromPrivateKey( + oasis.hash.hash(oasis.misc.fromString('oasis-runtime-sdk/test-keys: bob')), ); const csBob = new oasis.signature.BlindContextSigner(bob); @@ -203,7 +201,7 @@ export const playground = (async function () { const nonce1 = await accountsWrapper .queryNonce() .setArgs({ - address: await oasis.staking.addressFromPublicKey(alice.public()), + address: oasis.staking.addressFromPublicKey(alice.public()), }) .query(nic); const siAlice1 = /** @type {oasisRT.types.SignerInfo} */ ({ @@ -253,7 +251,7 @@ export const playground = (async function () { const nonce2 = await accountsWrapper .queryNonce() .setArgs({ - address: await oasis.staking.addressFromPublicKey(alice.public()), + address: oasis.staking.addressFromPublicKey(alice.public()), }) .query(nic); const siAlice2 = /** @type {oasisRT.types.SignerInfo} */ ({ @@ -347,7 +345,7 @@ export const playground = (async function () { ], threshold: 2, }); - const addr = await oasisRT.address.fromMultisigConfig(msConfig); + const addr = oasisRT.address.fromMultisigConfig(msConfig); const addrBech32 = oasis.staking.addressToBech32(addr); const refBech32 = 'oasis1qpcprk8jxpsjxw9fadxvzrv9ln7td69yus8rmtux'; console.log('address for sample config', addrBech32, 'reference', refBech32); diff --git a/client-sdk/ts-web/rt/test/address.test.ts b/client-sdk/ts-web/rt/test/address.test.ts index 0361945223..a28bb124de 100644 --- a/client-sdk/ts-web/rt/test/address.test.ts +++ b/client-sdk/ts-web/rt/test/address.test.ts @@ -4,7 +4,7 @@ describe('address', () => { describe('ed25519', () => { it('Should derive the address correctly', async () => { const pk = Buffer.from('utrdHlX///////////////////////////////////8=', 'base64'); - const address = await oasisRT.address.fromSigspec({ed25519: new Uint8Array(pk)}); + const address = oasisRT.address.fromSigspec({ed25519: new Uint8Array(pk)}); expect(oasisRT.address.toBech32(address)).toEqual( 'oasis1qryqqccycvckcxp453tflalujvlf78xymcdqw4vz', ); @@ -14,7 +14,7 @@ describe('address', () => { describe('secp256k1eth', () => { it('Should derive the address correctly', async () => { const pk = Buffer.from('Arra3R5V////////////////////////////////////', 'base64'); - const address = await oasisRT.address.fromSigspec({secp256k1eth: new Uint8Array(pk)}); + const address = oasisRT.address.fromSigspec({secp256k1eth: new Uint8Array(pk)}); expect(oasisRT.address.toBech32(address)).toEqual( 'oasis1qzd7akz24n6fxfhdhtk977s5857h3c6gf5583mcg', ); diff --git a/client-sdk/ts-web/signer-ledger/playground/src/index.js b/client-sdk/ts-web/signer-ledger/playground/src/index.js index e1fbb929c2..16a918b92a 100644 --- a/client-sdk/ts-web/signer-ledger/playground/src/index.js +++ b/client-sdk/ts-web/signer-ledger/playground/src/index.js @@ -9,7 +9,7 @@ async function play() { // Try Ledger signing. { - const dst = oasis.signature.NaclSigner.fromRandom('this key is not important'); + const dst = await oasis.signature.WebCryptoSigner.generate(false); const dstAddr = await oasis.staking.addressFromPublicKey(dst.public()); console.log('dst addr', oasis.staking.addressToBech32(dstAddr)); diff --git a/client-sdk/ts-web/signer-ledger/src/index.ts b/client-sdk/ts-web/signer-ledger/src/index.ts index 5a8cc84d31..5c5cce9718 100644 --- a/client-sdk/ts-web/signer-ledger/src/index.ts +++ b/client-sdk/ts-web/signer-ledger/src/index.ts @@ -61,7 +61,8 @@ export class LedgerContextSigner implements oasis.signature.ContextSigner { static async fromTransport(transport: Transport, keyNumber: number) { const app = new OasisApp(transport); - // Specification forthcoming. See https://github.com/oasisprotocol/oasis-core/pull/3656. + // Ledger clients use the "legacy" derivation path by default. + // https://github.com/oasisprotocol/cli/blob/v0.1.0/wallet/ledger/common.go#L15 const path = [44, 474, 0, 0, keyNumber]; const publicKeyResponse = successOrThrow(await app.publicKey(path), 'ledger public key'); return new LedgerContextSigner(app, path, u8FromBuf(publicKeyResponse.pk as Buffer)); diff --git a/client-sdk/ts-web/signer-tweetnacl/.gitignore b/client-sdk/ts-web/signer-tweetnacl/.gitignore new file mode 100644 index 0000000000..9c00274ec2 --- /dev/null +++ b/client-sdk/ts-web/signer-tweetnacl/.gitignore @@ -0,0 +1,3 @@ +/dist +/node_modules +/playground/dist/main.js diff --git a/client-sdk/ts-web/signer-tweetnacl/.prettierrc.js b/client-sdk/ts-web/signer-tweetnacl/.prettierrc.js new file mode 100644 index 0000000000..7ff25e0fb3 --- /dev/null +++ b/client-sdk/ts-web/signer-tweetnacl/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + bracketSpacing: false, + printWidth: 100, + semi: true, + singleQuote: true, + tabWidth: 4, +}; diff --git a/client-sdk/ts-web/signer-tweetnacl/docs/changelog.md b/client-sdk/ts-web/signer-tweetnacl/docs/changelog.md new file mode 100644 index 0000000000..1a35e721e8 --- /dev/null +++ b/client-sdk/ts-web/signer-tweetnacl/docs/changelog.md @@ -0,0 +1,14 @@ +# Changelog + +## Unreleased changes + +New features: + +- This is the `NaclSigner` class formerly in `@oasisprotocol/client`. Feel + free to continue using it for development. + +Breaking changes: + +- The `note` parameter is removed. Our opinion on using in-application-memory + keys is unchanged. But if you're taking the step of installing this library + called "signer-tweetnacl," you that it's using tweetnacl under the hood. diff --git a/client-sdk/ts-web/signer-tweetnacl/package.json b/client-sdk/ts-web/signer-tweetnacl/package.json new file mode 100644 index 0000000000..5df3c30ed5 --- /dev/null +++ b/client-sdk/ts-web/signer-tweetnacl/package.json @@ -0,0 +1,29 @@ +{ + "name": "@oasisprotocol/signer-tweetnacl", + "version": "1.0.0", + "license": "Apache-2.0", + "homepage": "https://github.com/oasisprotocol/oasis-sdk/tree/main/client-sdk/ts-web/signer-tweetnacl", + "repository": { + "type": "git", + "url": "https://github.com/oasisprotocol/oasis-sdk.git", + "directory": "client-sdk/ts-web/signer-tweetnacl" + }, + "files": [ + "dist" + ], + "main": "dist/index.js", + "scripts": { + "prepare": "tsc", + "fmt": "prettier --write src", + "lint": "prettier --check src" + }, + "dependencies": { + "@oasisprotocol/client": "^1.1.0", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "prettier": "^3.3.3", + "typedoc": "^0.26.7", + "typescript": "^5.6.2" + } +} diff --git a/client-sdk/ts-web/signer-tweetnacl/src/index.ts b/client-sdk/ts-web/signer-tweetnacl/src/index.ts new file mode 100644 index 0000000000..920333c682 --- /dev/null +++ b/client-sdk/ts-web/signer-tweetnacl/src/index.ts @@ -0,0 +1,61 @@ +import * as nacl from 'tweetnacl'; + +import * as oasis from '@oasisprotocol/client'; + +/** + * An in-memory signer based on tweetnacl. + */ +export class NaclSigner implements oasis.signature.Signer { + key: nacl.SignKeyPair; + + constructor(key: nacl.SignKeyPair) { + this.key = key; + } + + /** + * Generates a keypair from a random seed. + * @returns Instance of NaclSigner + */ + static fromRandom() { + const secret = new Uint8Array(32); + crypto.getRandomValues(secret); + return NaclSigner.fromSeed(secret); + } + + /** + * Instantiates from a given 64-bite `nacl.sign` secret key. + * @param secret Secret key + * @returns Instance of NaclSigner + */ + static fromSecret(secret: Uint8Array) { + const key = nacl.sign.keyPair.fromSecretKey(secret); + return new NaclSigner(key); + } + + /** + * Instantiates from a given 32-byte `nacl.sign` seed. + * @param seed Seed + * @returns Instance of NaclSigner + */ + static fromSeed(seed: Uint8Array) { + const key = nacl.sign.keyPair.fromSeed(seed); + return new NaclSigner(key); + } + + /** + * Returns the 32-byte public key of this key pair. + * @returns Public key + */ + public(): Uint8Array { + return this.key.publicKey; + } + + /** + * Signs the given message. + * @param message Bytes to sign + * @returns Signed message + */ + async sign(message: Uint8Array): Promise { + return nacl.sign.detached(message, this.key.secretKey); + } +} diff --git a/client-sdk/ts-web/signer-tweetnacl/tsconfig.json b/client-sdk/ts-web/signer-tweetnacl/tsconfig.json new file mode 100644 index 0000000000..7ecd805219 --- /dev/null +++ b/client-sdk/ts-web/signer-tweetnacl/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "outDir": "./dist/", + "noImplicitAny": true, + "strict": true, + "module": "CommonJS", + "target": "ES2020", + "declaration": true, + "esModuleInterop": true + }, + "include": [ + "src/**/*" + ], + "typedocOptions": { + "entryPoints": ["src/index.ts"], + "out": "docs/api", + "excludeInternal": true, + "excludePrivate": true + } +}