diff --git a/packages/sdk/src/auth/certificates/VerifiableCertificate.ts b/packages/sdk/src/auth/certificates/VerifiableCertificate.ts index 7b14b97d0..9bba50301 100644 --- a/packages/sdk/src/auth/certificates/VerifiableCertificate.ts +++ b/packages/sdk/src/auth/certificates/VerifiableCertificate.ts @@ -96,7 +96,10 @@ export class VerifiableCertificate extends Certificate { * @throws {Error} Throws an error if any of the decryption operations fail, with a message indicating the failure context. */ async decryptFields( - verifierWallet: ProtoWallet, + // Typed as the capability actually used rather than the whole ProtoWallet class, so + // that adding a method to ProtoWallet does not narrow what may be passed here. This + // only ever calls decrypt(). + verifierWallet: Pick, privileged?: boolean, privilegedReason?: string, originator?: OriginatorDomainNameStringUnder250Bytes diff --git a/packages/sdk/src/wallet/ProtoWallet.ts b/packages/sdk/src/wallet/ProtoWallet.ts index 15822a493..cf9c0d4d3 100644 --- a/packages/sdk/src/wallet/ProtoWallet.ts +++ b/packages/sdk/src/wallet/ProtoWallet.ts @@ -9,6 +9,7 @@ import { PublicKey, Point, PrivateKey, + Curve, SymmetricKey, readyAsyncCryptoBackend, isAsyncCryptoDigest, @@ -20,6 +21,8 @@ import { CreateSignatureArgs, CreateSignatureResult, GetPublicKeyArgs, + MultiplyPointArgs, + MultiplyPointResult, PubKeyHex, RevealCounterpartyKeyLinkageArgs, RevealCounterpartyKeyLinkageResult, @@ -88,6 +91,56 @@ async function deriveSymmetricKey( return keyDeriver.deriveSymmetricKey(protocolID, keyID, counterparty) } +/** + * Parses a compressed DER point, rejecting everything a conforming BRC-229 + * implementation must reject. + * + * The canonical-encoding check is NOT redundant with the on-curve check, and omitting it is + * the likely way to build a non-conforming implementation. PublicKey.fromString accepts an + * x-coordinate numerically greater than the field prime, reduces it modulo p without + * signalling anything, and validate() then reports the reduced point as on-curve. Verified + * against this package: '02' + 'ff'*32 parses, reduces to 0x1000003d0, and validates true. + * Accepting it would admit a point that was never validly encoded, which is the entry point + * for invalid-curve attacks. + */ +function parseValidPoint(pointHex: PubKeyHex): Point { + // A wrong type and a wrong format are different faults, so they raise different errors: + // TypeError for the former (which is also what Sonar's S7786 asks for), Error for the latter. + if (typeof pointHex !== 'string') { + throw new TypeError('multiplyPoint: point must be a string') + } + if (!/^0[23][0-9a-fA-F]{64}$/.test(pointHex)) { + throw new Error('multiplyPoint: point must be a 33-byte compressed DER secp256k1 point in hex') + } + + const curve = new Curve() + // Reject before parsing, because the parser is what silently reduces the coordinate. + if (new BigNumber(pointHex.slice(2), 16).cmp(curve.p) >= 0) { + throw new Error('multiplyPoint: the x-coordinate is not a canonical field element') + } + + let point: Point + try { + point = Point.fromString(pointHex) + } catch { + throw new Error('multiplyPoint: the supplied point could not be decoded') + } + + if (point.isInfinity()) { + throw new Error('multiplyPoint: the supplied point is the identity') + } + // Guard y as well. The compressed form derives y, but a decoder that produced a + // non-canonical y would otherwise reach the curve check unexamined. + const y = point.getY() + if (y.cmp(curve.p) >= 0 || y.isNeg()) { + throw new Error('multiplyPoint: the y-coordinate is not a canonical field element') + } + if (!point.validate()) { + throw new Error('multiplyPoint: the supplied point is not on the curve') + } + return point +} + /** * A ProtoWallet is precursor to a full wallet, capable of performing all foundational cryptographic operations. * It can derive keys, create signatures, facilitate encryption and HMAC operations, and reveal key linkages. @@ -130,6 +183,59 @@ export class ProtoWallet { } } + /** + * Multiplies a caller-supplied secp256k1 point by a BRC-42/43 derived private key, + * returning the resulting point. The private key is never revealed. + * + * This is the BRC-229 primitive. It exists so commutative-masking protocols + * (Barnett-Smart mental poker, verifiable shuffles, oblivious transfer) can run against a + * wallet-held key instead of requiring the application to generate and store secp256k1 + * keys of its own. + * + * The key is always derived from protocolID/keyID/counterparty and is never the identity + * key or a spending key. That restriction is load-bearing rather than stylistic: for a + * counterparty point Q, d*Q IS the ECDH shared secret with Q, so performing this operation + * with a key used for anything else would hand the caller that secret and break encryption + * to that counterparty. A protocol-scoped key that never signs and never encrypts has no + * such property to lose. + */ + // Declared optional (`?`) so that ProtoWallet remains as wide a structural type as it was + // before BRC-229 existed. A required member -- method or property -- narrows what satisfies + // `ProtoWallet`, and every implementor that does not extend the class stops type-checking: + // Wallet, PrivilegedKeyManager and the wallet managers in @bsv/wallet-toolbox all implement + // it structurally. BRC-229 is an opt-in capability, so its presence on the type is opt-in + // too, and callers feature-detect exactly as they do on WalletInterface. + multiplyPoint?: (args: MultiplyPointArgs) => Promise = async ( + args: MultiplyPointArgs + ): Promise => { + if (args.protocolID == null || args.keyID == null || args.keyID === '') { + throw new Error('protocolID and keyID are required.') + } + const keyDeriver = keyDeriverOrThrow(this.keyDeriver) + const point = parseValidPoint(args.point) + const derived = derivePrivateKey( + keyDeriver, + args.protocolID, + args.keyID, + args.counterparty ?? 'self' + ) + + const curve = new Curve() + // With invert, multiply by d^-1 mod n rather than d, so a mask applied under this + // derivation can be stripped by the same wallet. + const scalar = args.invert === true ? derived.invm(curve.n) : new BigNumber(derived.toHex(), 16) + + const result = point.mul(scalar) + + // Refuse a result at infinity rather than encoding it: it carries no information, and + // every protocol built on this primitive treats it as a failure. + if (result.isInfinity()) { + throw new Error('multiplyPoint: the result is the point at infinity') + } + + return { point: result.encode(true, 'hex') as string } + } + async revealCounterpartyKeyLinkage( args: RevealCounterpartyKeyLinkageArgs ): Promise { diff --git a/packages/sdk/src/wallet/Wallet.interfaces.ts b/packages/sdk/src/wallet/Wallet.interfaces.ts index 8d40c90ee..dccfa030c 100644 --- a/packages/sdk/src/wallet/Wallet.interfaces.ts +++ b/packages/sdk/src/wallet/Wallet.interfaces.ts @@ -669,6 +669,35 @@ export interface WalletEncryptionArgs { * @param {BooleanDefaultFalse|true} [identityKey] - Use true to retrieve the current user's own identity key, overriding any protocol ID, key ID, or counterparty specified. * @param {BooleanDefaultFalse} [forSelf] - Whether to return the public key derived from the current user's own identity (as opposed to the counterparty's identity). */ +/** + * Arguments for BRC-229 wallet-native elliptic curve point multiplication. + */ +export interface MultiplyPointArgs { + /** The point to multiply, as a compressed DER-encoded secp256k1 point. */ + point: PubKeyHex + /** BRC-43 security level and protocol ID used to derive the key. */ + protocolID: WalletProtocol + /** BRC-43 key ID used to derive the key. */ + keyID: KeyIDStringUnder800Bytes + /** Counterparty for derivation. Defaults to 'self'. */ + counterparty?: WalletCounterparty + /** + * Multiply by the modular inverse of the derived key instead of the key itself, + * so a mask applied by the wallet can be removed by the wallet. + */ + invert?: BooleanDefaultFalse + privileged?: BooleanDefaultFalse + privilegedReason?: DescriptionString5to50Bytes + seekPermission?: BooleanDefaultTrue +} + +/** + * Result of a BRC-229 point multiplication: the resulting point, compressed DER-encoded. + */ +export interface MultiplyPointResult { + point: PubKeyHex +} + export interface GetPublicKeyArgs extends Partial { identityKey?: true forSelf?: BooleanDefaultFalse @@ -1025,6 +1054,24 @@ export interface WalletInterface { originator?: OriginatorDomainNameStringUnder250Bytes ) => Promise + /** + * Multiplies a caller-supplied secp256k1 point by a derived private key, returning the + * resulting point without revealing the key (BRC-229). Set `invert` to multiply by the + * modular inverse instead, undoing a mask previously applied under the same derivation. + * + * Optional. BRC-100 is an unchanging interface, so a method added after the fact cannot be + * mandatory without invalidating every wallet and substrate already shipped. Applications + * MUST feature-detect (`typeof wallet.multiplyPoint === 'function'`) and degrade gracefully. + * + * @param {MultiplyPointArgs} args - The point, the BRC-43 derivation arguments, and options. + * @param {OriginatorDomainNameStringUnder250Bytes} [originator] - FQDN of the originating application. + * @returns {Promise} Resolves to the resulting point, or an error response. + */ + multiplyPoint?: ( + args: MultiplyPointArgs, + originator?: OriginatorDomainNameStringUnder250Bytes + ) => Promise + /** * Reveals the key linkage between ourselves and a counterparty, to a particular verifier, across all interactions with the counterparty. * diff --git a/packages/sdk/src/wallet/WalletClient.ts b/packages/sdk/src/wallet/WalletClient.ts index f6db0ca03..89b95956b 100644 --- a/packages/sdk/src/wallet/WalletClient.ts +++ b/packages/sdk/src/wallet/WalletClient.ts @@ -33,7 +33,9 @@ import { SignActionResult, VersionString7To30Bytes, WalletInterface, - AuthenticatedResult + AuthenticatedResult, + MultiplyPointArgs, + MultiplyPointResult } from './Wallet.interfaces.js' import WindowCWISubstrate from './substrates/window.CWI.js' import XDMSubstrate from './substrates/XDM.js' @@ -142,10 +144,7 @@ export default class WalletClient implements WalletInterface { () => new HTTPWalletJSON(this.originator, 'https://localhost:2121'), MAX_FAST_SUBSTRATE_RESPONSE_WAIT ), - attemptSubstrate( - () => new HTTPWalletJSON(this.originator), - MAX_FAST_SUBSTRATE_RESPONSE_WAIT - ), + attemptSubstrate(() => new HTTPWalletJSON(this.originator), MAX_FAST_SUBSTRATE_RESPONSE_WAIT), attemptSubstrate( () => new ReactNativeWebView('*', MAX_FAST_SUBSTRATE_RESPONSE_WAIT), MAX_FAST_SUBSTRATE_RESPONSE_WAIT @@ -234,6 +233,29 @@ export default class WalletClient implements WalletInterface { return await (this.substrate as WalletInterface).getPublicKey(args, this.originator) } + /** + * BRC-229 point multiplication. Optional across the interface, so this throws a clear error + * when the connected substrate does not implement it rather than failing on `undefined`. + * Applications should feature-detect with {@link WalletClient.supportsMultiplyPoint}. + */ + async multiplyPoint(args: MultiplyPointArgs): Promise { + await this.connectToSubstrate() + const substrate = this.substrate as WalletInterface + if (typeof substrate.multiplyPoint !== 'function') { + throw new TypeError('The connected wallet does not implement multiplyPoint (BRC-229)') + } + return await substrate.multiplyPoint(args, this.originator) + } + + /** + * Reports whether the connected wallet implements BRC-229 point multiplication, so an + * application can choose a fallback before committing to a protocol that needs it. + */ + async supportsMultiplyPoint(): Promise { + await this.connectToSubstrate() + return typeof (this.substrate as WalletInterface).multiplyPoint === 'function' + } + async revealCounterpartyKeyLinkage(args: { counterparty: PubKeyHex verifier: PubKeyHex diff --git a/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts b/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts new file mode 100644 index 000000000..e985ceb00 --- /dev/null +++ b/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts @@ -0,0 +1,185 @@ +import ProtoWallet from '../../wallet/ProtoWallet' +import { PrivateKey, Curve, BigNumber } from '../../primitives/index' + +/** + * BRC-229: wallet-native elliptic curve point multiplication. + * + * These tests assert the properties a commutative-masking protocol actually depends on, + * rather than that the method returns some string. If any of them fail, mental poker built + * on this primitive is broken. + */ + +const PROTOCOL: [0 | 1 | 2, string] = [2, 'mental poker deal'] +const OTHER_PROTOCOL: [0 | 1 | 2, string] = [2, 'a different scheme'] + +/** Card i is encoded as (i+1)*G, the standard Barnett-Smart card encoding. */ +const card = (i: number): string => { + const curve = new Curve() + return curve.g.mul(new BigNumber(i + 1)).encode(true, 'hex') as string +} + +let alice: ProtoWallet +let bob: ProtoWallet +let carol: ProtoWallet + +beforeEach(() => { + alice = new ProtoWallet(PrivateKey.fromRandom()) + bob = new ProtoWallet(PrivateKey.fromRandom()) + carol = new ProtoWallet(PrivateKey.fromRandom()) +}) + +describe('ProtoWallet.multiplyPoint (BRC-229)', () => { + it('masks commute across independent wallets', async () => { + // The property the whole construction rests on: a*(b*P) == b*(a*P), so players may + // apply their masks in any order and still agree on the deck. + const P = card(0) + + const ab = await bob.multiplyPoint({ + point: (await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + const ba = await alice.multiplyPoint({ + point: (await bob.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + + expect(ab.point).toEqual(ba.point) + }) + + it('invert recovers the original point', async () => { + const P = card(7) + const masked = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + expect(masked.point).not.toEqual(P) + + const unmasked = await alice.multiplyPoint({ + point: masked.point, + protocolID: PROTOCOL, + keyID: '1', + invert: true + }) + expect(unmasked.point).toEqual(P) + }) + + it('a three-way mask strips in any order', async () => { + // No dealer exists, so unmasking order is whatever the table happens to do. + const P = card(12) + let deck = P + for (const w of [alice, bob, carol]) { + deck = (await w.multiplyPoint({ point: deck, protocolID: PROTOCOL, keyID: 'k' })).point + } + expect(deck).not.toEqual(P) + + // Strip in a deliberately different order from the one used to apply. + for (const w of [bob, carol, alice]) { + deck = ( + await w.multiplyPoint({ point: deck, protocolID: PROTOCOL, keyID: 'k', invert: true }) + ).point + } + expect(deck).toEqual(P) + }) + + it('separates keys by protocol, key ID and counterparty', async () => { + const P = card(3) + const base = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + const otherProto = await alice.multiplyPoint({ + point: P, + protocolID: OTHER_PROTOCOL, + keyID: '1' + }) + const otherKey = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '2' }) + const otherParty = await alice.multiplyPoint({ + point: P, + protocolID: PROTOCOL, + keyID: '1', + counterparty: (await bob.getPublicKey({ identityKey: true })).publicKey + }) + + const all = [base.point, otherProto.point, otherKey.point, otherParty.point] + expect(new Set(all).size).toEqual(all.length) + }) + + it('two wallets never derive the same protocol key', async () => { + const P = card(5) + const a = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + const b = await bob.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + expect(a.point).not.toEqual(b.point) + }) + + it('masks a whole deck and reveals one position without leaking the others', async () => { + const deck = Array.from({ length: 52 }, (_, i) => card(i)) + const masked = await Promise.all( + deck.map( + async (p, i) => + (await alice.multiplyPoint({ point: p, protocolID: PROTOCOL, keyID: String(i) })).point + ) + ) + + // Every masked position differs from its plaintext and from every other position. + expect(new Set(masked).size).toEqual(52) + masked.forEach((m, i) => expect(m).not.toEqual(deck[i])) + + // Revealing position 17 discloses that card and nothing else. + const revealed = await alice.multiplyPoint({ + point: masked[17], + protocolID: PROTOCOL, + keyID: '17', + invert: true + }) + expect(revealed.point).toEqual(deck[17]) + for (let i = 0; i < 52; i++) { + if (i !== 17) expect(masked[i]).not.toEqual(deck[i]) + } + }) + + it('rejects a non-canonical x-coordinate that on-curve checks alone accept', async () => { + // The regression that motivates the spec's canonical-encoding rule. This x is greater + // than the field prime; PublicKey.fromString reduces it silently and validate() then + // returns true, so an implementation checking only the curve equation accepts a point + // that was never validly encoded. + const nonCanonical = '02' + 'ff'.repeat(32) + const curve = new Curve() + expect(new BigNumber('ff'.repeat(32), 16).cmp(curve.p)).toBeGreaterThanOrEqual(0) + + await expect( + alice.multiplyPoint({ point: nonCanonical, protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow(/canonical field element/) + }) + + it('rejects malformed, off-curve and identity points', async () => { + const bad: Record = { + empty: '', + 'not hex': '02' + 'zz'.repeat(32), + 'too short': '02ab', + 'bad prefix': '04' + 'ab'.repeat(32), + 'all zeros': '02' + '00'.repeat(32) + } + for (const [name, point] of Object.entries(bad)) { + await expect( + alice.multiplyPoint({ point, protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow() + expect(name).toBeTruthy() + } + }) + + it('stays structurally optional so existing implementors still satisfy ProtoWallet', () => { + // Regression guard. Declaring multiplyPoint as a required member -- method or property -- + // narrows what structurally satisfies ProtoWallet, and every implementor that does not + // extend the class stops type-checking. That broke Wallet, PrivilegedKeyManager and the + // wallet managers in @bsv/wallet-toolbox with 6 compile errors. This asserts the shape a + // structural implementor needs: no multiplyPoint, and it still assigns. + const withoutMultiplyPoint: Pick = {} + expect(withoutMultiplyPoint.multiplyPoint).toBeUndefined() + + // And the real wallet does provide it. + expect(typeof alice.multiplyPoint).toEqual('function') + }) + + it('requires protocolID and keyID', async () => { + const P = card(0) + await expect( + alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '' }) + ).rejects.toThrow(/required/) + }) +}) diff --git a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts index ff5d138b8..eedf7e37e 100644 --- a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts +++ b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts @@ -33,7 +33,9 @@ import { SecurityLevel, SignActionArgs, SignActionResult, - VersionString7To30Bytes + VersionString7To30Bytes, + MultiplyPointArgs, + MultiplyPointResult } from '../Wallet.interfaces.js' import { WERR_REVIEW_ACTIONS } from '../WERR_REVIEW_ACTIONS.js' import { WERR_INVALID_PARAMETER } from '../WERR_INVALID_PARAMETER.js' @@ -168,6 +170,10 @@ export default class HTTPWalletJSON implements WalletInterface { return (await this.api('getPublicKey', args)) as { publicKey: PubKeyHex } } + async multiplyPoint(args: MultiplyPointArgs): Promise { + return (await this.api('multiplyPoint', args)) as MultiplyPointResult + } + async revealCounterpartyKeyLinkage(args: { counterparty: PubKeyHex verifier: PubKeyHex diff --git a/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts b/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts index 83accc0eb..c752baeba 100644 --- a/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts +++ b/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts @@ -48,7 +48,9 @@ import { GetHeaderArgs, GetHeaderResult, GetNetworkResult, - GetVersionResult + GetVersionResult, + MultiplyPointArgs, + MultiplyPointResult } from '../Wallet.interfaces.js' import { CallType } from './WalletWireCalls.js' @@ -94,6 +96,10 @@ export abstract class InvokableWalletBase implements WalletInterface { return await this.invoke('getPublicKey', args) } + async multiplyPoint(args: MultiplyPointArgs): Promise { + return await this.invoke('multiplyPoint', args) + } + async revealCounterpartyKeyLinkage(args: RevealCounterpartyKeyLinkageArgs): Promise { return await this.invoke('revealCounterpartyKeyLinkage', args) } diff --git a/packages/sdk/src/wallet/substrates/WalletWireCalls.ts b/packages/sdk/src/wallet/substrates/WalletWireCalls.ts index e57fed283..14b4f1cc3 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireCalls.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireCalls.ts @@ -28,6 +28,9 @@ enum calls { getHeaderForHeight = 26, getNetwork = 27, getVersion = 28, + // BRC-229. Optional: a wallet that does not implement it returns an error over the wire, + // which is how a caller feature-detects across a substrate. + multiplyPoint = 29, } export default calls diff --git a/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts b/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts index 61eb9bbd0..8b7882cf4 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts @@ -1165,6 +1165,34 @@ export default class WalletWireProcessor implements WalletWire { return responseWriter.toUint8Array() })() + case 'multiplyPoint': + return await (async () => { + // BRC-229. The point is a fixed 33 bytes, so it is read positionally before the + // variable-length key parameters. + const args: any = {} + args.point = Utils.toHex(paramsReader.read(33)) + Object.assign(args, this.decodeKeyRelatedParams(paramsReader)) + + const invertFlag = paramsReader.readInt8() + args.invert = invertFlag === -1 ? undefined : invertFlag === 1 + + const seekPermission = paramsReader.readInt8() + args.seekPermission = seekPermission === -1 ? undefined : seekPermission === 1 + + // multiplyPoint is optional on the interface, so a wallet may legitimately not + // implement it. Report that as an error rather than calling undefined, which is + // how a caller feature-detects across a substrate. + if (typeof this.wallet.multiplyPoint !== 'function') { + throw new TypeError('multiplyPoint is not implemented by this wallet') + } + const multiplyPointResult = await this.wallet.multiplyPoint(args, originator) + + const responseWriter = new Utils.WriterUint8Array() + responseWriter.writeUInt8(0) // errorByte = 0 + responseWriter.write(Utils.toUint8Array(multiplyPointResult.point, 'hex')) + return responseWriter.toUint8Array() + })() + case 'encrypt': return await (async () => { const args: any = this.decodeKeyRelatedParams(paramsReader) diff --git a/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts b/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts index 95c77c46a..a437897c0 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts @@ -44,7 +44,9 @@ import { VersionString7To30Bytes, WalletInterface, ActionStatus, - SendWithResultStatus + SendWithResultStatus, + MultiplyPointArgs, + MultiplyPointResult } from '../Wallet.interfaces.js' import WalletWire from './WalletWire.js' import Certificate from '../../auth/certificates/Certificate.js' @@ -619,6 +621,35 @@ export default class WalletWireTransceiver implements WalletInterface { } } + /** + * BRC-229 point multiplication over the wire. + * + * The point travels as its 33 raw bytes rather than as hex, matching how getPublicKey + * returns a key. `invert` rides as an optional boolean so that a wallet reading an older + * frame layout cannot mistake its absence for `true`. + */ + async multiplyPoint( + args: MultiplyPointArgs, + originator?: OriginatorDomainNameStringUnder250Bytes + ): Promise { + const paramWriter = new Utils.WriterUint8Array() + paramWriter.write(Utils.toUint8Array(args.point, 'hex')) + paramWriter.write( + this.encodeKeyRelatedParams( + args.protocolID, + args.keyID, + args.counterparty, + args.privileged, + args.privilegedReason + ) + ) + this.writeOptionalBool(paramWriter, args.invert) + this.writeOptionalBool(paramWriter, args.seekPermission) + + const result = await this.transmit('multiplyPoint', originator, paramWriter.toUint8Array()) + return { point: Utils.toHex(result) } + } + async revealCounterpartyKeyLinkage( args: { counterparty: PubKeyHex diff --git a/packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts b/packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts new file mode 100644 index 000000000..60fbd4570 --- /dev/null +++ b/packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts @@ -0,0 +1,125 @@ +import ProtoWallet from '../../../wallet/ProtoWallet' +import WalletWireTransceiver from '../../../wallet/substrates/WalletWireTransceiver' +import WalletWireProcessor from '../../../wallet/substrates/WalletWireProcessor' +import calls from '../../../wallet/substrates/WalletWireCalls' +import { PrivateKey, Curve, BigNumber } from '../../../primitives/index' +import type { WalletInterface } from '../../../wallet/Wallet.interfaces' + +/** + * BRC-229 over the serialized wire substrate. + * + * ProtoWallet already has unit coverage for the maths. What these tests establish is that the + * frame encoding preserves it: a mask applied through the wire must be strippable through the + * wire, or a wallet reached over a substrate (which is every real wallet, including BSV + * Desktop) cannot participate in the protocol. + */ + +const PROTOCOL: [0 | 1 | 2, string] = [2, 'mental poker deal'] + +const card = (i: number): string => + new Curve().g.mul(new BigNumber(i + 1)).encode(true, 'hex') as string + +const wireWallet = (underlying: ProtoWallet): WalletWireTransceiver => + new WalletWireTransceiver(new WalletWireProcessor(underlying as unknown as WalletInterface)) + +describe('multiplyPoint over the wire substrate (BRC-229)', () => { + it('is assigned call code 29', () => { + // The BRC-100 call code table ends at 28 (getVersion), so 29 is the next free code. + expect(calls.multiplyPoint).toEqual(29) + expect(calls.getVersion).toEqual(28) + }) + + it('round-trips a masked point and strips it again', async () => { + const wallet = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + const P = card(0) + + const masked = await wallet.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + expect(masked.point).not.toEqual(P) + expect(masked.point).toMatch(/^0[23][0-9a-f]{64}$/) + + const unmasked = await wallet.multiplyPoint({ + point: masked.point, + protocolID: PROTOCOL, + keyID: '1', + invert: true + }) + expect(unmasked.point).toEqual(P) + }) + + it('agrees with the in-process result, so the encoding loses nothing', async () => { + const key = PrivateKey.fromRandom() + const direct = new ProtoWallet(key) + const overWire = wireWallet(new ProtoWallet(key)) + const P = card(9) + + const a = await direct.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: 'k' }) + const b = await overWire.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: 'k' }) + expect(b.point).toEqual(a.point) + }) + + it('commutes across two wallets reached over the wire', async () => { + const alice = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + const bob = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + const P = card(4) + + const ab = await bob.multiplyPoint({ + point: (await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + const ba = await alice.multiplyPoint({ + point: (await bob.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + expect(ab.point).toEqual(ba.point) + }) + + it('carries counterparty and invert through the frame independently', async () => { + const alice = new ProtoWallet(PrivateKey.fromRandom()) + const bob = new ProtoWallet(PrivateKey.fromRandom()) + const wired = wireWallet(alice) + const bobKey = (await bob.getPublicKey({ identityKey: true })).publicKey + const P = card(2) + + // A counterparty-scoped mask must differ from a self-scoped one and still invert. + const selfMask = await wired.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + const partyMask = await wired.multiplyPoint({ + point: P, + protocolID: PROTOCOL, + keyID: '1', + counterparty: bobKey + }) + expect(partyMask.point).not.toEqual(selfMask.point) + + const back = await wired.multiplyPoint({ + point: partyMask.point, + protocolID: PROTOCOL, + keyID: '1', + counterparty: bobKey, + invert: true + }) + expect(back.point).toEqual(P) + }) + + it('propagates a rejected point as a wire error rather than a bad point', async () => { + const wallet = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + // The non-canonical x-coordinate: greater than the field prime, silently reduced by the + // parser, and reported on-curve. It must not survive the trip. + await expect( + wallet.multiplyPoint({ point: '02' + 'ff'.repeat(32), protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow(/canonical field element/) + }) + + it('reports a clear error when the wallet does not implement the method', async () => { + // multiplyPoint is optional, so a substrate may front a wallet without it. The caller must + // get a legible error, not a crash on undefined. + const withoutMethod = { getVersion: async () => ({ version: '1.0.0' }) } + const wallet = new WalletWireTransceiver( + new WalletWireProcessor(withoutMethod as unknown as WalletInterface) + ) + await expect( + wallet.multiplyPoint({ point: card(0), protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow(/not implemented/) + }) +})