js-stellar-base/src/auth.js

import xdr from './xdr';

import { Keypair } from './keypair';
import { StrKey } from './strkey';
import { Networks } from './network';
import { hash } from './hashing';

import { Address } from './address';
import { nativeToScVal } from './scval';

/**
 * @async
 * @callback SigningCallback A callback for signing an XDR structure
 * representing all of the details necessary to authorize an invocation tree.
 *
 * @param {xdr.HashIdPreimage} preimage   the entire authorization envelope
 *    whose hash you should sign, so that you can inspect the entire structure
 *    if necessary (rather than blindly signing a hash)
 *
 * @returns {Promise<Uint8Array>}   the signature of the raw payload (which is
 *    the sha256 hash of the preimage bytes, so `hash(preimage.toXDR())`) signed
 *    by the key corresponding to the public key in the entry you pass to
 *    {@link authorizeEntry} (decipherable from its
 *    `credentials().address().address()`)
 */

/**
 * Actually authorizes an existing authorization entry using the given the
 * credentials and expiration details, returning a signed copy.
 *
 * This "fills out" the authorization entry with a signature, indicating to the
 * {@link Operation.invokeHostFunction} its attached to that:
 *   - a particular identity (i.e. signing {@link Keypair} or other signer)
 *   - approving the execution of an invocation tree (i.e. a simulation-acquired
 *     {@link xdr.SorobanAuthorizedInvocation} or otherwise built)
 *   - on a particular network (uniquely identified by its passphrase, see
 *     {@link Networks})
 *   - until a particular ledger sequence is reached.
 *
 * This one lets you pass a either a {@link Keypair} (or, more accurately,
 * anything with a `sign(Buffer): Buffer` method) or a callback function (see
 * {@link SigningCallback}) to handle signing the envelope hash.
 *
 * @param {xdr.SorobanAuthorizationEntry} entry   an unsigned authorization entr
 * @param {Keypair | SigningCallback} signer  either a {@link Keypair} instance
 *    or a function which takes a payload (a
 *    {@link xdr.HashIdPreimageSorobanAuthorization} instance) input and returns
 *    the signature of the hash of the raw payload bytes (where the signing key
 *    should correspond to the address in the `entry`)
 * @param {number} validUntilLedgerSeq   the (exclusive) future ledger sequence
 *    number until which this authorization entry should be valid (if
 *    `currentLedgerSeq==validUntil`, this is expired))
 * @param {string} [networkPassphrase]  the network passphrase is incorprated
 *    into the signature (see {@link Networks} for options)
 *
 * @returns {Promise<xdr.SorobanAuthorizationEntry>} a promise for an
 *    authorization entry that you can pass along to
 *    {@link Operation.invokeHostFunction}
 *
 * @see authorizeInvocation
 * @example
 * import { Server, Transaction, Networks, authorizeEntry } from 'soroban-client';
 *
 * // Assume signPayloadCallback is a well-formed signing callback.
 * //
 * // It might, for example, pop up a modal from a browser extension, send the
 * // transaction to a third-party service for signing, or just do simple
 * // signing via Keypair like it does here:
 * function signPayloadCallback(payload) {
 *    return signer.sign(hash(payload.toXDR());
 * }
 *
 * function multiPartyAuth(
 *    server: Server,
 *    // assume this involves multi-party auth
 *    tx: Transaction,
 * ) {
 *    return server
 *      .simulateTransaction(tx)
 *      .then((simResult) => {
 *          tx.operations[0].auth.map(entry =>
 *            authorizeEntry(
 *              entry,
 *              signPayloadCallback,
 *              currentLedger + 1000,
 *              Networks.FUTURENET);
 *          ));
 *
 *          return server.prepareTransaction(tx, Networks.FUTURENET, simResult);
 *      })
 *      .then((preppedTx) => {
 *        preppedTx.sign(source);
 *        return server.sendTransaction(preppedTx);
 *      });
 * }
 */
export async function authorizeEntry(
  entry,
  signer,
  validUntilLedgerSeq,
  networkPassphrase = Networks.FUTURENET
) {
  // no-op if it's source account auth
  if (
    entry.credentials().switch().value !==
    xdr.SorobanCredentialsType.sorobanCredentialsAddress().value
  ) {
    return entry;
  }

  const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR());

  /** @type {xdr.SorobanAddressCredentials} */
  const addrAuth = clone.credentials().address();
  addrAuth.signatureExpirationLedger(validUntilLedgerSeq);

  const networkId = hash(Buffer.from(networkPassphrase));
  const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
    new xdr.HashIdPreimageSorobanAuthorization({
      networkId,
      nonce: addrAuth.nonce(),
      invocation: clone.rootInvocation(),
      signatureExpirationLedger: addrAuth.signatureExpirationLedger()
    })
  );
  const payload = hash(preimage.toXDR());

  let signature;
  if (typeof signer === 'function') {
    signature = Buffer.from(await signer(preimage));
  } else {
    signature = Buffer.from(signer.sign(payload));
  }
  const publicKey = Address.fromScAddress(addrAuth.address()).toString();

  if (!Keypair.fromPublicKey(publicKey).verify(payload, signature)) {
    throw new Error(`signature doesn't match payload`);
  }

  // This structure is defined here:
  // https://soroban.stellar.org/docs/fundamentals-and-concepts/invoking-contracts-with-transactions#stellar-account-signatures
  //
  // Encoding a contract structure as an ScVal means the map keys are supposed
  // to be symbols, hence the forced typing here.
  const sigScVal = nativeToScVal(
    {
      public_key: StrKey.decodeEd25519PublicKey(publicKey),
      signature
    },
    {
      type: {
        public_key: ['symbol', null],
        signature: ['symbol', null]
      }
    }
  );

  addrAuth.signature(xdr.ScVal.scvVec([sigScVal]));
  return clone;
}

/**
 * This builds an entry from scratch, allowing you to express authorization as a
 * function of:
 *   - a particular identity (i.e. signing {@link Keypair} or other signer)
 *   - approving the execution of an invocation tree (i.e. a simulation-acquired
 *     {@link xdr.SorobanAuthorizedInvocation} or otherwise built)
 *   - on a particular network (uniquely identified by its passphrase, see
 *     {@link Networks})
 *   - until a particular ledger sequence is reached.
 *
 * This is in contrast to {@link authorizeEntry}, which signs an existing entry.
 *
 * @param {Keypair | SigningCallback} signer  either a {@link Keypair} instance
 *    (or anything with a `.sign(buf): Buffer-like` method) or a function which
 *    takes a payload (a {@link xdr.HashIdPreimageSorobanAuthorization}
 *    instance) input and returns the signature of the hash of the raw payload
 *    bytes (where the signing key should correspond to the address in the
 *    `entry`)
 * @param {number}  validUntilLedgerSeq  the (exclusive) future ledger sequence
 *    number until which this authorization entry should be valid (if
 *    `currentLedgerSeq==validUntilLedgerSeq`, this is expired))
 * @param {xdr.SorobanAuthorizedInvocation} invocation the invocation tree that
 *    we're authorizing (likely, this comes from transaction simulation)
 * @param {string}  [publicKey]   the public identity of the signer (when
 *    providing a {@link Keypair} to `signer`, this can be omitted, as it just
 *    uses {@link Keypair.publicKey})
 * @param {string}  [networkPassphrase]   the network passphrase is incorprated
 *    into the signature (see {@link Networks} for options, default:
 *    {@link Networks.FUTURENET})
 *
 * @returns {Promise<xdr.SorobanAuthorizationEntry>} a promise for an
 *    authorization entry that you can pass along to
 *    {@link Operation.invokeHostFunction}
 *
 * @see authorizeEntry
 */
export function authorizeInvocation(
  signer,
  validUntilLedgerSeq,
  invocation,
  publicKey = '',
  networkPassphrase = Networks.FUTURENET
) {
  // 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 pk = publicKey || signer.publicKey();
  if (!pk) {
    throw new Error(`authorizeInvocation requires publicKey parameter`);
  }

  const entry = new xdr.SorobanAuthorizationEntry({
    rootInvocation: invocation,
    credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(
      new xdr.SorobanAddressCredentials({
        address: new Address(pk).toScAddress(),
        nonce,
        signatureExpirationLedger: 0, // replaced
        signature: xdr.ScVal.scvVec([]) // replaced
      })
    )
  });

  return authorizeEntry(entry, signer, validUntilLedgerSeq, networkPassphrase);
}

function bytesToInt64(bytes) {
  // eslint-disable-next-line no-bitwise
  return bytes.subarray(0, 8).reduce((accum, b) => (accum << 8) | b, 0);
}