diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 00000000..c500b945 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,199 @@ +import xdr from './xdr'; + +import { StrKey } from './strkey'; +import { Keypair } from './keypair'; +import { hash } from './hashing'; + +import { Address } from './address'; +import { nativeToScVal } from './scval'; + +/** + * This builds an authorization entry that indicates to + * {@link Operation.invokeHostFunction} that a particular identity (i.e. signing + * {@link Keypair} or other signer) approves the execution of an invocation tree + * (i.e. a simulation-acquired {@link xdr.SorobanAuthorizedInvocation}) on a + * particular network (uniquely identified by its passphrase, see + * {@link Networks}) until a particular ledger sequence is reached. + * + * This enables building an {@link xdr.SorobanAuthorizationEntry} without + * worrying about how to combine {@link buildAuthEnvelope} and + * {@link buildAuthEntry}, while those allow advanced, asynchronous, two-step + * building+signing of the authorization entries. + * + * This one lets you pass a either a {@link Keypair} or a callback function to + * handle signing the envelope hash. + * + * @param {Keypair} signer the identity keypair authorizing this invocation + * @param {string} networkPassphrase the network passphrase is incorprated + * into the signature (see {@link Networks} for options) + * @param {number} validUntil the (exclusive) future ledger sequence number + * until which this authorization entry should be valid (if + * `currentLedgerSeq==validUntil`, this is expired)) + * @param {xdr.SorobanAuthorizedInvocation} invocation the invocation tree that + * we're authorizing (likely, this comes from transaction simulation) + * + * @returns {xdr.SorobanAuthorizationEntry} an authorization entry that you can + * pass along to {@link Operation.invokeHostFunction} + */ +export function authorizeInvocation( + signer, + networkPassphrase, + validUntil, + invocation +) { + const preimage = buildAuthEnvelope(networkPassphrase, validUntil, invocation); + const input = hash(preimage.toXDR()); + const signature = signer.sign(input); + return buildAuthEntry(preimage, signature, signer.publicKey()); +} + +/** + * This works like {@link authorizeInvocation}, but allows passing an + * asynchronous callback as a "signing method" (e.g. {@link Keypair.sign}) and a + * public key instead of a specific {@link Keypair}. + * + * This is to make two-step authorization (i.e. custom signing flows) easier. + * + * @borrows authorizeInvocation + * + * @param {string} publicKey the public identity that is authorizing this + * invocation via its signature + * @param {function(Buffer): Buffer} signingMethod a function which takes + * an input bytearray and returns its signature as signed by the private key + * corresponding to the `publicKey` parameter + * @param {string} networkPassphrase the network passphrase is incorprated + * into the signature (see {@link Networks} for options) + * @param {number} validUntil the (exclusive) future ledger sequence number + * until which this authorization entry should be valid (if + * `currentLedgerSeq==validUntil`, this is expired)) + * @param {xdr.SorobanAuthorizedInvocation} invocation the invocation tree that + * we're authorizing (likely, this comes from transaction simulation) + * + * @param {xdr.SorobanAuthorizedInvocation} invocation + * + * @returns {Promise} + * @see authorizeInvocation + */ +export async function authorizeInvocationCallback( + publicKey, + signingMethod, + networkPassphrase, + validUntil, + invocation +) { + const preimage = buildAuthEnvelope(networkPassphrase, validUntil, invocation); + const input = hash(preimage.toXDR()); + const signature = await signingMethod(input); + return buildAuthEntry(preimage, signature, publicKey); +} + +/** + * Builds an {@link xdr.HashIdPreimage} that, when hashed and signed, can be + * used to build an {@link xdr.SorobanAuthorizationEntry} via + * {@link buildAuthEnvelope} to approve {@link Operation.invokeHostFunction} + * invocations. + * + * The envelope built here will approve the execution of an invocation tree + * (i.e. a simulation-acquired {@link xdr.SorobanAuthorizedInvocation}) on a + * particular network (uniquely identified by its passphrase, see + * {@link Networks}) until a particular ledger sequence is reached (exclusive). + * + * @param {string} networkPassphrase the network passphrase is incorprated + * into the signature (see {@link Networks} for options) + * @param {number} validUntil the (exclusive) future ledger sequence number + * until which this authorization entry should be valid + * @param {xdr.SorobanAuthorizedInvocation} invocation the invocation tree that + * we're authorizing (likely, this comes from transaction simulation) + * + * @returns {xdr.HashIdPreimage} a preimage envelope that, when hashed and + * signed, represents the signature necessary to build a proper + * {@link xdr.SorobanAuthorizationEntry} via {@link buildAuthEntry}. + */ +export function buildAuthEnvelope(networkPassphrase, validUntil, invocation) { + // We use keypairs as a source of randomness for the nonce to avoid mucking + // with any crypto dependencies. Note that this just has to be random and + // unique, not cryptographically secure, so it's fine. + const kp = Keypair.random().rawPublicKey(); + const nonce = new xdr.Int64(bytesToInt64(kp)); + + const networkId = hash(Buffer.from(networkPassphrase)); + const envelope = new xdr.HashIdPreimageSorobanAuthorization({ + networkId, + invocation, + nonce, + signatureExpirationLedger: validUntil + }); + + return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(envelope); +} + +/** + * Builds an auth entry with a signed invocation tree. + * + * You should first build the envelope using {@link buildAuthEnvelope}. If you + * have a signing {@link Keypair}, you can use the more convenient + * {@link authorizeInvocation} to do signing for you. + * + * @param {xdr.HashIdPreimage} envelope an envelope to represent the call tree + * being signed, probably built by {@link buildAuthEnvelope} + * @param {Buffer|Uint8Array} signature a signature of the hash of the + * envelope by the private key corresponding to `publicKey` (in other words, + * `signature = sign(hash(envelope))`) + * @param {string} publicKey the public identity that signed this envelope + * + * @returns {xdr.SorobanAuthorizationEntry} + * + * @throws {Error} if `verify(hash(envelope), signature, publicKey)` does not + * pass, meaning one of the arguments was not passed or built correctly + * @throws {TypeError} if the envelope does not hold an + * {@link xdr.HashIdPreimageSorobanAuthorization} instance + */ +export function buildAuthEntry(envelope, signature, publicKey) { + // ensure this identity signed this envelope correctly + if ( + !Keypair.fromPublicKey(publicKey).verify(hash(envelope.toXDR()), signature) + ) { + throw new Error(`signature does not match envelope or identity`); + } + + if ( + envelope.switch() !== xdr.EnvelopeType.envelopeTypeSorobanAuthorization() + ) { + throw new TypeError( + `expected sorobanAuthorization envelope, got ${envelope.switch().name}` + ); + } + + const auth = envelope.sorobanAuthorization(); + return new xdr.SorobanAuthorizationEntry({ + rootInvocation: auth.invocation(), + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: new Address(publicKey).toScAddress(), + nonce: auth.nonce(), + signatureExpirationLedger: auth.signatureExpirationLedger(), + signatureArgs: [ + nativeToScVal( + { + public_key: StrKey.decodeEd25519PublicKey(publicKey), + signature + }, + { + // force the keys to be interpreted as symbols (expected for + // Soroban [contracttype]s) + type: { + public_key: ['symbol', null], + signature: ['symbol', null] + } + } + ) + ] + }) + ) + }); +} + +function bytesToInt64(bytes) { + // eslint-disable-next-line no-bitwise + return bytes.subarray(0, 8).reduce((accum, b) => (accum << 8) | b, 0); +} diff --git a/src/index.js b/src/index.js index b5d387d4..ba597458 100644 --- a/src/index.js +++ b/src/index.js @@ -55,5 +55,6 @@ export * from './numbers'; export * from './scval'; export * from './events'; export * from './sorobandata_builder'; +export * from './auth'; export default module.exports; diff --git a/src/numbers/xdr_large_int.js b/src/numbers/xdr_large_int.js index 899d97ee..8d01116e 100644 --- a/src/numbers/xdr_large_int.js +++ b/src/numbers/xdr_large_int.js @@ -18,7 +18,7 @@ import xdr from '../xdr'; * @param {string} type - force a specific data type. the type choices * are: 'i64', 'u64', 'i128', 'u128', 'i256', and 'u256' (default: the * smallest one that fits the `value`) - * @param {number|bigint|string|ScInt|Array} values + * @param {number|bigint|string|XdrLargeInt|ScInt|Array} values * - a list of integer-like values interpreted in big-endian order */ export class XdrLargeInt { diff --git a/test/unit/auth_test.js b/test/unit/auth_test.js new file mode 100644 index 00000000..5c41b268 --- /dev/null +++ b/test/unit/auth_test.js @@ -0,0 +1,78 @@ +const xdr = StellarBase.xdr; + +describe('building authorization entries', function () { + const contractId = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + const kp = StellarBase.Keypair.random(); + const invocation = new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.SorobanAuthorizedContractFunction({ + contractAddress: new StellarBase.Address(contractId).toScAddress(), + functionName: 'hello', + args: [StellarBase.nativeToScVal('world!')] + }) + ), + subInvocations: [] + }); + + it('built an mock invocation correctly', function () { + invocation.toXDR(); + }); + + it('works with keypairs', function () { + const entry = StellarBase.authorizeInvocation( + kp, + StellarBase.Networks.FUTURENET, + 123, + invocation + ); + + let cred = entry.credentials().address(); + let args = cred.signatureArgs().map((v) => StellarBase.scValToNative(v)); + + expect(cred.signatureExpirationLedger()).to.equal(123); + expect(args.length).to.equal(1); + expect( + StellarBase.StrKey.encodeEd25519PublicKey(args[0]['public_key']) + ).to.equal(kp.publicKey()); + expect(entry.rootInvocation()).to.eql(invocation); + + // TODO: Validate the signature using the XDR structure. + + const nextEntry = StellarBase.authorizeInvocation( + kp, + StellarBase.Networks.FUTURENET, + 123, + invocation + ); + const nextCred = nextEntry.credentials().address(); + + expect(cred.nonce()).to.not.equal(nextCred.nonce()); + }); + + it('works asynchronously', function (done) { + StellarBase.authorizeInvocationCallback( + kp.publicKey(), + async (v) => kp.sign(v), + StellarBase.Networks.FUTURENET, + 123, + invocation + ) + .then((entry) => { + let cred = entry.credentials().address(); + let args = cred + .signatureArgs() + .map((v) => StellarBase.scValToNative(v)); + + expect(cred.signatureExpirationLedger()).to.equal(123); + expect(args.length).to.equal(1); + expect( + StellarBase.StrKey.encodeEd25519PublicKey(args[0]['public_key']) + ).to.equal(kp.publicKey()); + expect(entry.rootInvocation()).to.eql(invocation); + + done(); + }) + .catch((err) => done(err)); + }); +}); diff --git a/test/unit/contract_test.js b/test/unit/contract_test.js index 8e3d8cc8..01ec5334 100644 --- a/test/unit/contract_test.js +++ b/test/unit/contract_test.js @@ -1,4 +1,4 @@ -const NULL_ADDRESS = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM' +const NULL_ADDRESS = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; describe('Contract', function () { describe('constructor', function () { @@ -13,7 +13,7 @@ describe('Contract', function () { expect(() => { new StellarBase.Contract('0'.repeat(63) + '1'); }).to.throw(); - }) + }); it('throws on invalid ids', function () { expect(() => { @@ -25,9 +25,7 @@ describe('Contract', function () { describe('address', function () { it('returns the contract address', function () { let contract = new StellarBase.Contract(NULL_ADDRESS); - expect(contract.address().toString()).to.equal( - NULL_ADDRESS - ); + expect(contract.address().toString()).to.equal(NULL_ADDRESS); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 5e466e95..941c90de 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1132,7 +1132,7 @@ export function humanizeEvents( ): SorobanEvent[]; export class SorobanDataBuilder { - constructor(data?: string | xdr.SorobanTransactionData | null); + constructor(data?: string | xdr.SorobanTransactionData); setRefundableFee(fee: IntLike): SorobanDataBuilder; setResources( @@ -1152,3 +1152,30 @@ export class SorobanDataBuilder { build(): xdr.SorobanTransactionData; } + +export function authorizeInvocation( + signer: Keypair, + networkPassphrase: string, + validUntil: number, + invocation: xdr.SorobanAuthorizedInvocation +): xdr.SorobanAuthorizationEntry; + +export function authorizeInvocationCallback( + publicKey: string, + signingMethod: (input: Buffer) => Buffer, + networkPassphrase: string, + validUntil: number, + invocation: xdr.SorobanAuthorizedInvocation +): xdr.SorobanAuthorizationEntry; + +export function buildAuthEnvelope( + networkPassphrase: string, + validUntil: number, + invocation: xdr.SorobanAuthorizedInvocation +): xdr.HashIdPreimage; + +export function buildAuthEntry( + envelope: xdr.HashIdPreimage, + signature: Buffer | Uint8Array, + publicKey: string +): xdr.SorobanAuthorizationEntry;