Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion modules/sdk-coin-xlm/src/xlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,25 +127,70 @@ interface TransactionMemo {
type?: string;
}

/**
* A signer added, re-weighted or (weight 0) removed by a setOptions operation.
* ed25519PublicKey and ed25519SignedPayload keys are strkeys; sha256Hash and preAuthTx keys are hex.
*/
interface TransactionSigner {
type: 'ed25519PublicKey' | 'sha256Hash' | 'preAuthTx' | 'ed25519SignedPayload';
key: string;
weight?: number;
}

interface TransactionOperation {
// 'unsupported' marks an operation explain does not model, so it is never silently absent from the result
type: string;
coin: string;
// set only when the operation names its own source account, i.e. it runs against an account other than the tx source
source?: string;
limit?: string;
asset?: stellar.Asset;
// accountConfig (setOptions) operation fields
// unsupported operation field: the stellar operation type that was not modelled
operationType?: string;
// accountConfig (setOptions) operation fields; each is undefined when the operation does not set it
setFlags?: number;
clearFlags?: number;
masterWeight?: number;
lowThreshold?: number;
medThreshold?: number;
highThreshold?: number;
homeDomain?: string;
inflationDest?: string;
signer?: TransactionSigner;
// authorizeTrustline (setTrustLineFlags) operation fields
trustor?: string;
// Each trustline flag is true when the operation sets it, false when it clears it, and undefined when the
// operation does not touch it. undefined is therefore never a revoke, and an operation can deauthorize a
// trustline through authorizedToMaintainLiabilities or authorized, so consumers must read all three flags.
authorized?: boolean;
authorizedToMaintainLiabilities?: boolean;
clawbackEnabled?: boolean;
// clawback operation fields
from?: string;
amount?: string;
// clawback asset, so consumers need not parse the coin name to label the amount
assetCode?: string;
assetIssuer?: string;
}

function describeSigner(signer: stellar.Signer): TransactionSigner {
const weight = signer.weight === undefined ? undefined : Number(signer.weight);
if ('ed25519PublicKey' in signer) {
return { type: 'ed25519PublicKey', key: signer.ed25519PublicKey, weight };
}
if ('sha256Hash' in signer) {
return { type: 'sha256Hash', key: signer.sha256Hash.toString('hex'), weight };
}
if ('preAuthTx' in signer) {
return { type: 'preAuthTx', key: signer.preAuthTx.toString('hex'), weight };
}
return { type: 'ed25519SignedPayload', key: signer.ed25519SignedPayload, weight };
}

interface TransactionOutput extends BaseTransactionOutput {
coin: string;
// set only when the operation names its own source account, i.e. it runs against an account other than the tx source
source?: string;
}

interface TransactionExplanation extends BaseTransactionExplanation {
Expand Down Expand Up @@ -1052,6 +1097,7 @@ export class Xlm extends BaseCoin {
),
address: op.destination + memoId,
coin,
source: op.source,
};

if (!_.isUndefined(spendAmounts[coin])) {
Expand All @@ -1072,6 +1118,7 @@ export class Xlm extends BaseCoin {
operations.push({
type: op.type,
coin: this.getTokenNameFromStellarAsset(asset),
source: op.source,
asset,
limit: this.bigUnitsToBaseUnits(op.limit),
});
Expand All @@ -1080,26 +1127,41 @@ export class Xlm extends BaseCoin {
operations.push({
type: 'accountConfig',
coin: this.getChain(),
source: op.source,
setFlags: setOptionsOp.setFlags,
clearFlags: setOptionsOp.clearFlags,
masterWeight: setOptionsOp.masterWeight,
lowThreshold: setOptionsOp.lowThreshold,
medThreshold: setOptionsOp.medThreshold,
highThreshold: setOptionsOp.highThreshold,
homeDomain: setOptionsOp.homeDomain,
inflationDest: setOptionsOp.inflationDest,
signer: setOptionsOp.signer ? describeSigner(setOptionsOp.signer) : undefined,
});
Comment on lines 1129 to 1140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing, security-relevant: A reviewer still sees no sign of a setOptions that adds a signer, which is the most dangerous account change. The accountConfig entry at xlm.ts:1093-1104 omits setOptionsOp.signer. A signer-only op shows up as accountConfig with every field undefined, which looks like a no-op. Fix: report signer (key and weight, with weight 0 meaning removal) on accountConfig entries. The PR says this omission is intentional, but the stated goal is that no signed account change is hidden.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: a txBase64 holding stellar.Operation.setOptions({ signer: { ed25519PublicKey: attacker, weight: 3 } }) is passed to explainTransaction. The setOptions branch at xlm.ts:1091-1104 copies setFlags, clearFlags, masterWeight, the three thresholds, homeDomain and inflationDest, but never signer. The result is { type: 'accountConfig', coin } with every other field undefined. verifyAccountConfigOperations (xlm.ts:1269-1281) lists 'signer' among the fields that matter, citing signer:{attacker,weight:3}, but that check only runs on the verify path, not for explain consumers such as offline vault review. The base behaves the same way. Still, this PR extends the setOptions output so that a reviewer can see every account change, and it leaves out the field that hands over control of the account. The PR description calls the omission intentional but gives no mitigation for explain readers.

Verification: Trigger: a setOptions op that sets only signer is passed to explainTransaction. The setOptions branch at modules/sdk-coin-xlm/src/xlm.ts:1091-1104 never reads setOptionsOp.signer. So a signer-only op comes out as { type: 'accountConfig', coin }, which looks like a no-op. The 'signer' guard in verifyAccountConfigOperations only runs in verifyTransaction. The base had the same omission.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 416b1f5. accountConfig entries now carry signer as { type, key, weight } (ed25519 public key, sha256 hash, pre-auth tx and signed payload signers). Weight 0 is kept and means removal, so a signer-only setOptions no longer reads as an empty entry. Tests cover add, removal, hex-encoded hash and pre-auth signers, and the unset case.

} else if (op.type === 'setTrustLineFlags') {
const setTrustLineFlagsOp = op as stellar.Operation.SetTrustLineFlags;
Comment on lines 1129 to 1142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing, security-relevant: Reviewers cannot tell when any operation runs against a different account, because no explained operation reports its op-level source. The explain loop at xlm.ts:1047-1124 ignores op.source for setOptions, setTrustLineFlags, clawback, changeTrust, payment and createAccount. A setOptions with source set to another wallet looks the same as one for the tx source. Fix: add op.source (undefined when unset) to every operations entry and output the loop builds, including the new unsupported entries. The verify path already rejects source on setOptions.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: a transaction whose setOptions or clawback op carries source: otherAccount, for example stellar.Operation.setOptions({ source: victim, masterWeight: 0 }), is passed to explainTransaction. Each branch of the forEach at xlm.ts:1047-1125 builds its entry only from op-specific fields, and none reads op.source. The explanation therefore shows masterWeight: 0 as if it applied to the transaction's own account. verifyAccountConfigOperations (xlm.ts:1263-1281) names 'source' as dangerous, saying an op redirected to another account runs under that account's thresholds. That check only runs on the verify path and only for setOptions. The base omits source in the same way. This PR's stated aim is that a reviewer sees everything a transaction does, and it adds fields to these same entries without this one.

Verification: The trigger is a txBase64 whose op carries an op-level source for another account, passed to explainTransaction. No branch of the loop in modules/sdk-coin-xlm/src/xlm.ts (lines 1047-1126) reads op.source. The base explain loop also never reported source. The source check in verifyAccountConfigOperations (1269-1277) covers only setOptions on the verify path, not explain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 416b1f5. Every operations entry (including unsupported) and every outputs entry now carries source, set only when the operation names its own source account and undefined otherwise. Tests cover an op-level source on each operation kind and its absence.

operations.push({
type: 'authorizeTrustline',
coin: this.getTokenNameFromStellarAsset(setTrustLineFlagsOp.asset),
source: op.source,
trustor: setTrustLineFlagsOp.trustor,
authorized: setTrustLineFlagsOp.flags?.authorized,
authorizedToMaintainLiabilities: setTrustLineFlagsOp.flags?.authorizedToMaintainLiabilities,
clawbackEnabled: setTrustLineFlagsOp.flags?.clawbackEnabled,
});
} else if (op.type === 'clawback') {
const clawbackOp = op as stellar.Operation.Clawback;
operations.push({
type: 'clawback',
coin: this.getTokenNameFromStellarAsset(clawbackOp.asset),
source: op.source,
from: clawbackOp.from,
amount: this.bigUnitsToBaseUnits(clawbackOp.amount),
assetCode: clawbackOp.asset.code,
assetIssuer: clawbackOp.asset.issuer,
});
} else {
operations.push({ type: 'unsupported', coin: this.getChain(), operationType: op.type, source: op.source });
}
});

Expand Down
235 changes: 235 additions & 0 deletions modules/sdk-coin-xlm/test/unit/xlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1981,5 +1981,240 @@ describe('XLM:', function () {
// 10 big units clawed back => 10 * 1e7 = 100000000 base units in the explanation.
clawbackOperation.amount.should.equal('100000000');
});

it('should report the asset code and issuer of a clawback so the amount can be labelled without parsing the coin name', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.clawback({ from: trustorAddress, asset: bstAsset, amount: '1' }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
const clawbackOperation = explanation.operations[0];
clawbackOperation.assetCode.should.equal('BST');
clawbackOperation.assetIssuer.should.equal(issuerAddress);
});

it('should leave authorized undefined when a setTrustLineFlags op does not touch the authorized flag', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setTrustLineFlags({
trustor: trustorAddress,
asset: bstAsset,
flags: { authorizedToMaintainLiabilities: true },
}),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
const authorizeTrustlineOperation = explanation.operations[0];
authorizeTrustlineOperation.type.should.equal('authorizeTrustline');
// Consumers must be able to tell "not changed" (undefined) apart from a revoke (false).
should.not.exist(authorizeTrustlineOperation.authorized);
});

it('should report authorized false for a revoke', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setTrustLineFlags({
trustor: trustorAddress,
asset: bstAsset,
flags: { authorized: false },
}),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
explanation.operations[0].authorized.should.equal(false);
});

it('should report all three trustline flags so a deauthorization through any of them is visible', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setTrustLineFlags({
trustor: trustorAddress,
asset: bstAsset,
flags: { authorized: false, authorizedToMaintainLiabilities: true, clawbackEnabled: false },
}),
stellar.Operation.setTrustLineFlags({
trustor: trustorAddress,
asset: bstAsset,
flags: { authorizedToMaintainLiabilities: false },
}),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
// A downgrade to maintain-liabilities is not a full revoke, and clearing maintain-liabilities can
// deauthorize a trustline while `authorized` stays undefined.
const [downgrade, clearMaintainLiabilities] = explanation.operations;
downgrade.authorized.should.equal(false);
downgrade.authorizedToMaintainLiabilities.should.equal(true);
downgrade.clawbackEnabled.should.equal(false);
should.not.exist(clearMaintainLiabilities.authorized);
clearMaintainLiabilities.authorizedToMaintainLiabilities.should.equal(false);
should.not.exist(clearMaintainLiabilities.clawbackEnabled);
});

describe('setOptions fields beyond flags and lowThreshold', () => {
const inflationDestination = 'GAFVCOWZWSJEAFOKBEBO2B4QITJ2YXN6YIYG6BUURQINVDVW4OPS3OL6';

it('should report every setOptions field the transaction sets', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setOptions({
masterWeight: 0,
lowThreshold: 2,
medThreshold: 3,
highThreshold: 4,
homeDomain: 'example.com',
inflationDest: inflationDestination,
}),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
const accountConfigOperation = explanation.operations[0];
accountConfigOperation.type.should.equal('accountConfig');
// masterWeight 0 is a real value (it disables the master key) and must not be dropped as falsy.
accountConfigOperation.masterWeight.should.equal(0);
accountConfigOperation.lowThreshold.should.equal(2);
accountConfigOperation.medThreshold.should.equal(3);
accountConfigOperation.highThreshold.should.equal(4);
accountConfigOperation.homeDomain.should.equal('example.com');
accountConfigOperation.inflationDest.should.equal(inflationDestination);
});

it('should leave unset setOptions fields undefined', async () => {
const txBase64 = buildTxBase64([stellar.Operation.setOptions({ setFlags: stellar.AuthRequiredFlag })]);
const explanation = await basecoin.explainTransaction({ txBase64 });
const accountConfigOperation = explanation.operations[0];
accountConfigOperation.setFlags.should.equal(stellar.AuthRequiredFlag);
should.not.exist(accountConfigOperation.masterWeight);
should.not.exist(accountConfigOperation.medThreshold);
should.not.exist(accountConfigOperation.highThreshold);
should.not.exist(accountConfigOperation.homeDomain);
should.not.exist(accountConfigOperation.inflationDest);
});

describe('signer', () => {
const signerAddress = 'GBTL47RTFR5EKMZSXWOQU735WBK7LRPPDIDK3JTNTCZZ7NUBBRDTVSK2';

it('should report a signer-only setOptions so it does not read as an empty accountConfig', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setOptions({ signer: { ed25519PublicKey: signerAddress, weight: 3 } }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
explanation.operations[0].type.should.equal('accountConfig');
explanation.operations[0].signer.should.deepEqual({
type: 'ed25519PublicKey',
key: signerAddress,
weight: 3,
});
});

it('should report a signer removal as weight 0', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setOptions({ signer: { ed25519PublicKey: signerAddress, weight: 0 } }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
// weight 0 is the removal encoding and must not be dropped as falsy.
explanation.operations[0].signer.weight.should.equal(0);
});

it('should hex-encode hash and pre-auth transaction signers', async () => {
const hashSignerBytes = Buffer.alloc(32, 7);
const preAuthSignerBytes = Buffer.alloc(32, 9);
const txBase64 = buildTxBase64([
stellar.Operation.setOptions({ signer: { sha256Hash: hashSignerBytes, weight: 1 } }),
stellar.Operation.setOptions({ signer: { preAuthTx: preAuthSignerBytes, weight: 2 } }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
explanation.operations[0].signer.should.deepEqual({
type: 'sha256Hash',
key: hashSignerBytes.toString('hex'),
weight: 1,
});
explanation.operations[1].signer.should.deepEqual({
type: 'preAuthTx',
key: preAuthSignerBytes.toString('hex'),
weight: 2,
});
});

it('should leave signer undefined when the setOptions does not carry one', async () => {
const txBase64 = buildTxBase64([stellar.Operation.setOptions({ masterWeight: 0 })]);
const explanation = await basecoin.explainTransaction({ txBase64 });
should.not.exist(explanation.operations[0].signer);
});
});
});

it('should include an unsupported entry for an operation type explain does not model', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.clawback({ from: trustorAddress, asset: bstAsset, amount: '1' }),
stellar.Operation.bumpSequence({ bumpTo: '100' }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
// A signed operation must never be missing from the explanation, or a reviewer cannot see it.
explanation.operations.length.should.equal(2);
explanation.operations[0].type.should.equal('clawback');
explanation.operations[1].type.should.equal('unsupported');
explanation.operations[1].operationType.should.equal('bumpSequence');
});

it('should not add unsupported entries for payment and trustline operations explain already models', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.payment({ destination: trustorAddress, asset: stellar.Asset.native(), amount: '1' }),
stellar.Operation.changeTrust({ asset: bstAsset, limit: '1000' }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
// payment is reported through outputs, changeTrust through operations; neither is "unsupported".
explanation.outputs.length.should.equal(1);
explanation.operations.map((operation) => operation.type).should.deepEqual(['changeTrust']);
});

it('should report outputs and exactly one unsupported entry, in order, for a payment mixed with an unmodelled op', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.payment({ destination: trustorAddress, asset: stellar.Asset.native(), amount: '1' }),
stellar.Operation.bumpSequence({ bumpTo: '100' }),
stellar.Operation.clawback({ from: trustorAddress, asset: bstAsset, amount: '1' }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
explanation.outputs.length.should.equal(1);
// Operations keep transaction order, so a reviewer can line them up with the signed transaction.
explanation.operations.map((operation) => operation.type).should.deepEqual(['unsupported', 'clawback']);
explanation.operations[0].operationType.should.equal('bumpSequence');
});

describe('operation source', () => {
const otherSourceAddress = 'GAFVCOWZWSJEAFOKBEBO2B4QITJ2YXN6YIYG6BUURQINVDVW4OPS3OL6';

it('should report an operation-level source on every operation kind that names one', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setOptions({ source: otherSourceAddress, masterWeight: 0 }),
stellar.Operation.setTrustLineFlags({
source: otherSourceAddress,
trustor: trustorAddress,
asset: bstAsset,
flags: { authorized: true },
}),
stellar.Operation.clawback({
source: otherSourceAddress,
from: trustorAddress,
asset: bstAsset,
amount: '1',
}),
stellar.Operation.changeTrust({ source: otherSourceAddress, asset: bstAsset, limit: '1000' }),
stellar.Operation.bumpSequence({ source: otherSourceAddress, bumpTo: '100' }),
stellar.Operation.payment({
source: otherSourceAddress,
destination: trustorAddress,
asset: stellar.Asset.native(),
amount: '1',
}),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
// An op that runs against another account would otherwise read as if it applied to the tx source.
explanation.operations.length.should.equal(5);
explanation.operations.forEach((operation) => operation.source.should.equal(otherSourceAddress));
explanation.outputs[0].source.should.equal(otherSourceAddress);
});

it('should leave source undefined when an operation does not name one', async () => {
const txBase64 = buildTxBase64([
stellar.Operation.setOptions({ masterWeight: 0 }),
stellar.Operation.payment({ destination: trustorAddress, asset: stellar.Asset.native(), amount: '1' }),
]);
const explanation = await basecoin.explainTransaction({ txBase64 });
should.not.exist(explanation.operations[0].source);
should.not.exist(explanation.outputs[0].source);
});
});
});
});
Loading