Skip to content

Commit

Permalink
Add helpers method to build authorization entries. (#663)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shaptic authored Aug 4, 2023
1 parent 4113bb9 commit f5beb36
Show file tree
Hide file tree
Showing 6 changed files with 310 additions and 7 deletions.
199 changes: 199 additions & 0 deletions src/auth.js
Original file line number Diff line number Diff line change
@@ -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<xdr.SorobanAuthorizationEntry>}
* @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);
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,6 @@ export * from './numbers';
export * from './scval';
export * from './events';
export * from './sorobandata_builder';
export * from './auth';

export default module.exports;
2 changes: 1 addition & 1 deletion src/numbers/xdr_large_int.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number|bigint|string|ScInt>} values
* @param {number|bigint|string|XdrLargeInt|ScInt|Array<number|bigint|string|XdrLargeInt|ScInt>} values
* - a list of integer-like values interpreted in big-endian order
*/
export class XdrLargeInt {
Expand Down
78 changes: 78 additions & 0 deletions test/unit/auth_test.js
Original file line number Diff line number Diff line change
@@ -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));
});
});
8 changes: 3 additions & 5 deletions test/unit/contract_test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const NULL_ADDRESS = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'
const NULL_ADDRESS = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM';

describe('Contract', function () {
describe('constructor', function () {
Expand All @@ -13,7 +13,7 @@ describe('Contract', function () {
expect(() => {
new StellarBase.Contract('0'.repeat(63) + '1');
}).to.throw();
})
});

it('throws on invalid ids', function () {
expect(() => {
Expand All @@ -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);
});
});

Expand Down
29 changes: 28 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;

0 comments on commit f5beb36

Please sign in to comment.