Skip to content

Commit 71a7dd6

Browse files
committed
feat(account-lib): verify encoded message
TICKET: COIN-4769
1 parent a152f42 commit 71a7dd6

File tree

15 files changed

+288
-62
lines changed

15 files changed

+288
-62
lines changed

modules/abstract-eth/src/lib/messages/eip191/eip191Message.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@ export class EIP191Message extends BaseMessage {
1515
* Returns the hash of the EIP-191 prefixed message
1616
*/
1717
async getSignablePayload(): Promise<string | Buffer> {
18-
if (!this.signablePayload) {
19-
const prefix = `\u0019Ethereum Signed Message:\n${this.payload.length}`;
20-
this.signablePayload = Buffer.from(prefix.concat(this.payload)).toString('hex');
21-
}
18+
const prefix = `\u0019Ethereum Signed Message:\n${this.payload.length}`;
19+
this.signablePayload = Buffer.from(prefix.concat(this.payload)).toString('hex');
2220
return this.signablePayload;
2321
}
2422
}

modules/abstract-eth/test/unit/messages/eip191/eip191Message.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,6 @@ describe('EIP191 Message', () => {
6161
signablePayload.should.equal(expectedPayload);
6262
});
6363

64-
it('should reuse existing signable payload if already set', async () => {
65-
const message = new EIP191Message({
66-
coinConfig: fixtures.coin,
67-
payload: fixtures.messages.validMessage,
68-
signablePayload: 'predefined-payload',
69-
});
70-
71-
const signablePayload = await message.getSignablePayload();
72-
signablePayload.should.equal('predefined-payload');
73-
});
74-
7564
it('should maintain signatures and signers correctly', () => {
7665
const message = new EIP191Message({
7766
coinConfig: fixtures.coin,
@@ -132,7 +121,6 @@ describe('EIP191 Message', () => {
132121
signatures: [fixtures.eip191.signature],
133122
signers: [fixtures.eip191.signer],
134123
metadata: fixtures.eip191.metadata,
135-
signablePayload: 'test-signable-payload',
136124
});
137125

138126
const broadcastFormat = await message.toBroadcastFormat();
@@ -143,7 +131,9 @@ describe('EIP191 Message', () => {
143131
broadcastFormat.serializedSignatures?.should.deepEqual(expectedSerializedSignatures);
144132
broadcastFormat.signers?.should.deepEqual([fixtures.eip191.signer]);
145133
broadcastFormat.metadata!.should.deepEqual(fixtures.eip191.metadata);
146-
broadcastFormat.signablePayload!.should.equal('dGVzdC1zaWduYWJsZS1wYXlsb2Fk');
134+
broadcastFormat.signablePayload!.should.equal(
135+
'MTk0NTc0Njg2NTcyNjU3NTZkMjA1MzY5Njc2ZTY1NjQyMDRkNjU3MzczNjE2NzY1M2EwYTMxMzM0ODY1NmM2YzZmMmMyMDc3NmY3MjZjNjQyMQ=='
136+
);
147137
});
148138

149139
it('should convert to broadcast string correctly', async () => {

modules/account-lib/src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
acountLibCrypto,
1010
BaseMessageBuilderFactory,
1111
BuildMessageError,
12+
MessageStandardType,
1213
} from '@bitgo/sdk-core';
1314
import { BaseCoin as CoinConfig, CoinFeature, coins } from '@bitgo/statics';
1415
export { Ed25519BIP32, Eddsa };
@@ -202,6 +203,8 @@ export { Soneium };
202203
import * as Vet from '@bitgo/sdk-coin-vet';
203204
export { Vet };
204205

206+
import { validateAgainstMessageTemplates } from './utils';
207+
205208
const coinBuilderMap = {
206209
trx: Trx.WrappedBuilder,
207210
ttrx: Trx.WrappedBuilder,
@@ -393,3 +396,36 @@ export function registerMessageBuilderFactory<T extends BaseMessageBuilderFactor
393396
coinMessageBuilderFactoryMap[coinName] = messageBuilderFactory;
394397
return factory;
395398
}
399+
400+
/**
401+
* Verify a message against the given encoded payload.
402+
*
403+
* @param {string} coinName - The name of the coin.
404+
* @param {string} messageRaw - The raw message to verify.
405+
* @param {string} messageEncoded - The encoded message to verify against.
406+
* @param {MessageStandardType} messageStandardType - The type of message standard.
407+
* @param {Record<string, unknown>} [metadata] - Optional metadata for verification.
408+
* @returns {Promise<boolean>} - Returns true if the verification is successful, false otherwise.
409+
*/
410+
export async function verifyMessage(
411+
coinName: string,
412+
messageRaw: string,
413+
messageEncoded: string,
414+
messageStandardType: MessageStandardType,
415+
metadata?: Record<string, unknown>,
416+
): Promise<boolean> {
417+
try {
418+
const messageBuilderFactory = getMessageBuilderFactory(coinName);
419+
const messageBuilder = messageBuilderFactory.getMessageBuilder(messageStandardType);
420+
messageBuilder.setPayload(messageRaw);
421+
const message = await messageBuilder.build();
422+
const isValidMessageEncoded = await message.verifyEncodedPayload(messageEncoded, metadata);
423+
if (!isValidMessageEncoded) {
424+
return false;
425+
}
426+
return validateAgainstMessageTemplates(messageRaw);
427+
} catch (e) {
428+
console.error(`Error verifying message for coin ${coinName}:`, e);
429+
return false;
430+
}
431+
}

modules/account-lib/src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export function register(coinName: string): BaseUtils {
1818
}
1919
throw new NotSupported(`${coinName} util factory not supported`);
2020
}
21+
22+
export * from './messages';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export const MIDNIGHT_TNC_HASH =
2+
'6bf2adf825baa496729e2eac1e895ebc77973744bce67f44276bf6006f5c21de863ed121e11828d8fc0241773191e26dc1134803a681a9a98ba0ae812553db24';
3+
4+
/*
5+
* matches a message that starts with "STAR ", followed by a number,
6+
* then " to addr" or " to addr_test1", followed by a 50+ character alphanumeric address,
7+
* and ends with the midnight TnC hash
8+
*/
9+
const MIDNIGHT_GLACIER_DROP_CLAIM_MESSAGE_TEMPLATE = `STAR \\d+ to addr(?:1|_test1)[a-z0-9]{50,} ${MIDNIGHT_TNC_HASH}`;
10+
11+
/**
12+
* @file Utility functions for validating messages against whitelisted templates.
13+
* This is used to ensure that only specific message formats are accepted.
14+
*/
15+
const whitelistedMessageTemplates = [MIDNIGHT_GLACIER_DROP_CLAIM_MESSAGE_TEMPLATE];
16+
17+
/**
18+
* Validates a message against a set of whitelisted templates.
19+
* The templates can contain placeholders like {{variable}} which will be replaced with a wildcard in the regex.
20+
*
21+
* @param {string} messageRaw - The raw message to validate.
22+
* @returns {boolean} - Returns true if the message matches any of the whitelisted templates, false otherwise.
23+
*/
24+
export function validateAgainstMessageTemplates(messageRaw: string): boolean {
25+
return whitelistedMessageTemplates.some((template) => {
26+
const regex = new RegExp(`^${template}$`, 's'); // 's' flag to match newlines
27+
return regex.test(messageRaw);
28+
});
29+
}

modules/account-lib/test/unit/fixtures.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ export class MockMessage extends BaseMessage {
4646
}
4747

4848
async getSignablePayload(): Promise<string | Buffer> {
49-
if (this.signablePayload) {
50-
return this.signablePayload;
51-
}
52-
return Buffer.from(this.payload);
49+
this.signablePayload = Buffer.from(this.payload);
50+
return this.signablePayload;
5351
}
5452
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import should from 'should';
2+
import { MIDNIGHT_TNC_HASH, validateAgainstMessageTemplates } from '../../../../src/utils';
3+
4+
describe('Message validation', () => {
5+
describe('validateAgainstMessageTemplates', () => {
6+
const adaTestnetDestinationAddress = 'addr_test1vz7xs7ceu4xx9n5xn57lfe86vrwddqpp77vjwq5ptlkh49cqy3wur';
7+
const adaMainnetDestinationAddress =
8+
'addr1q9k6u7lhf467y2f8skr2dafldx2npsd8fymq0mslnj0t44nd4ealwnt4ug5j0pvx5m6n76v4xrq6wjfkqlhpl8y7httq2m9cmu';
9+
10+
it('should validate testnet message matching the Midnight glacier drop claim template', () => {
11+
const messageRaw = `STAR 100 to ${adaTestnetDestinationAddress} ${MIDNIGHT_TNC_HASH}`;
12+
13+
const result = validateAgainstMessageTemplates(messageRaw);
14+
15+
should.equal(result, true);
16+
});
17+
18+
it('should validate mainnet message matching the Midnight glacier drop claim template', () => {
19+
const messageRaw = `STAR 100 to ${adaMainnetDestinationAddress} ${MIDNIGHT_TNC_HASH}`;
20+
21+
const result = validateAgainstMessageTemplates(messageRaw);
22+
23+
should.equal(result, true);
24+
});
25+
26+
it('should not validate message with incorrect format', () => {
27+
const messageRaw = `INCORRECT 100 to ${adaTestnetDestinationAddress} ${MIDNIGHT_TNC_HASH}`;
28+
29+
const result = validateAgainstMessageTemplates(messageRaw);
30+
31+
should.equal(result, false);
32+
});
33+
34+
it('should not validate message with missing parts', () => {
35+
// Missing "to addr" part
36+
const messageRaw = `STAR 100 ${MIDNIGHT_TNC_HASH}`;
37+
38+
const result = validateAgainstMessageTemplates(messageRaw);
39+
40+
should.equal(result, false);
41+
});
42+
43+
it('should not validate message with incorrect hash', () => {
44+
// Different hash
45+
const incorrectHash =
46+
'5af1adf825baa496729e2eac1e895ebc77973744bce67f44276bf6006f5c21de863ed121e11828d8fc0241773191e26dc1134803a681a9a98ba0ae812553db24';
47+
const messageRaw = `STAR 100 to ${adaTestnetDestinationAddress} ${incorrectHash}`;
48+
49+
const result = validateAgainstMessageTemplates(messageRaw);
50+
51+
should.equal(result, false);
52+
});
53+
54+
it('should handle empty message', () => {
55+
const result = validateAgainstMessageTemplates('');
56+
57+
should.equal(result, false);
58+
});
59+
60+
it('should not validate message with special regex characters', () => {
61+
const messageRaw = `STAR shade.with+special*chars to addr.with[special]chars ${MIDNIGHT_TNC_HASH}`;
62+
63+
const result = validateAgainstMessageTemplates(messageRaw);
64+
65+
should.equal(result, false);
66+
});
67+
});
68+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import should from 'should';
2+
import * as accountLib from '../../src';
3+
import { MessageStandardType } from '@bitgo/sdk-core';
4+
import { MIDNIGHT_TNC_HASH } from '../../src/utils';
5+
6+
describe('verifyMessage', () => {
7+
const adaTestnetOriginAddress = 'addr_test1wz4h6068hs93n8j5ar88fgzz6sfnw8krng09xx0mmf36m8c7j9yap';
8+
const adaTestnetDestinationAddress = 'addr_test1vz7xs7ceu4xx9n5xn57lfe86vrwddqpp77vjwq5ptlkh49cqy3wur';
9+
const testnetMessageRaw = `STAR 100 to ${adaTestnetDestinationAddress} ${MIDNIGHT_TNC_HASH}`;
10+
11+
describe('EIP191 Message', function () {
12+
const eip191MessageBuilder = accountLib
13+
.getMessageBuilderFactory('eth')
14+
.getMessageBuilder(MessageStandardType.EIP191);
15+
16+
it('should throw error if coin is not supported', async () => {
17+
const coinName = 'unsupportedCoin';
18+
const messageRaw = 'test message';
19+
const messageEncoded = 'encoded message';
20+
const messageStandardType = MessageStandardType.EIP191;
21+
22+
const result = await accountLib.verifyMessage(coinName, messageRaw, messageEncoded, messageStandardType);
23+
should.equal(result, false);
24+
});
25+
26+
it('should verify EIP191 message successfully when both checks pass', async () => {
27+
const coinName = 'eth';
28+
eip191MessageBuilder.setPayload(testnetMessageRaw);
29+
const message = await eip191MessageBuilder.build();
30+
const messageEncodedHex = (await message.getSignablePayload()).toString('hex');
31+
32+
const result = await accountLib.verifyMessage(
33+
coinName,
34+
testnetMessageRaw,
35+
messageEncodedHex,
36+
MessageStandardType.EIP191,
37+
);
38+
should.equal(result, true);
39+
});
40+
41+
it('should return false if template validation fails', async () => {
42+
const coinName = 'eth';
43+
const messageRaw = 'Invalid message format'; // Doesn't match the template
44+
eip191MessageBuilder.setPayload(messageRaw);
45+
const message = await eip191MessageBuilder.build();
46+
const messageEncodedHex = (await message.getSignablePayload()).toString('hex');
47+
48+
const result = await accountLib.verifyMessage(
49+
coinName,
50+
testnetMessageRaw,
51+
messageEncodedHex,
52+
MessageStandardType.EIP191,
53+
);
54+
should.equal(result, false);
55+
});
56+
});
57+
58+
describe('CIP8 Message', function () {
59+
const cip8MessageBuilder = accountLib.getMessageBuilderFactory('ada').getMessageBuilder(MessageStandardType.CIP8);
60+
61+
it('should check metadata for verifying CIP8 message', async () => {
62+
const coinName = 'ada';
63+
cip8MessageBuilder.setPayload(testnetMessageRaw);
64+
cip8MessageBuilder.addSigner(adaTestnetOriginAddress);
65+
const message = await cip8MessageBuilder.build();
66+
const messageEncodedHex = (await message.getSignablePayload()).toString('hex');
67+
68+
// metadata is required for CIP8 message verification
69+
let result = await accountLib.verifyMessage(
70+
coinName,
71+
testnetMessageRaw,
72+
messageEncodedHex,
73+
MessageStandardType.CIP8,
74+
);
75+
should.equal(result, false);
76+
77+
const metadata = {
78+
signers: [adaTestnetOriginAddress],
79+
};
80+
result = await accountLib.verifyMessage(
81+
coinName,
82+
testnetMessageRaw,
83+
messageEncodedHex,
84+
MessageStandardType.CIP8,
85+
metadata,
86+
);
87+
should.equal(result, true);
88+
});
89+
});
90+
});

modules/sdk-coin-ada/src/lib/messages/cip8/cip8Message.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@ export class Cip8Message extends BaseMessage {
1717
* Returns the hash of the CIP-8 prefixed message
1818
*/
1919
async getSignablePayload(): Promise<string | Buffer> {
20-
if (!this.signablePayload) {
21-
const { addressCborBytes } = this.validateAndGetCommonSetup();
22-
const { sigStructureCborBytes } = createCSLSigStructure(addressCborBytes, this.payload);
23-
this.signablePayload = Buffer.from(sigStructureCborBytes);
24-
}
20+
const { addressCborBytes } = this.validateAndGetCommonSetup();
21+
const { sigStructureCborBytes } = createCSLSigStructure(addressCborBytes, this.payload);
22+
this.signablePayload = Buffer.from(sigStructureCborBytes);
2523
return this.signablePayload;
2624
}
2725

@@ -63,6 +61,31 @@ export class Cip8Message extends BaseMessage {
6361
];
6462
}
6563

64+
/**
65+
* Verifies the encoded payload against the provided metadata
66+
* @param messageEncodedHex The hex-encoded message to verify
67+
* @param metadata Metadata containing signer addresses
68+
* @returns True if the encoded payload matches the expected format, false otherwise
69+
*/
70+
async verifyEncodedPayload(messageEncodedHex: string, metadata?: Record<string, unknown>): Promise<boolean> {
71+
if (!metadata) {
72+
throw new Error('Metadata is required for verifying the encoded payload');
73+
}
74+
const signers = metadata.signers as string[];
75+
if (signers.length === 0) {
76+
throw new Error('At least one signer address is required in metadata for verification');
77+
}
78+
this.addSigner(signers[0]);
79+
const signablePayload = await this.getSignablePayload();
80+
let signablePayloadHex: string;
81+
if (Buffer.isBuffer(signablePayload)) {
82+
signablePayloadHex = signablePayload.toString('hex');
83+
} else {
84+
signablePayloadHex = signablePayload;
85+
}
86+
return signablePayloadHex === messageEncodedHex;
87+
}
88+
6689
/**
6790
* Validates required fields and returns common setup objects
6891
* @private

modules/sdk-coin-ada/test/unit/messages/cip8/cip8Message.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ describe('Cip8Message', function () {
9090

9191
should.exist(result);
9292
should.exist(result.addressCborBytes);
93-
should.ok(Buffer.isBuffer(result.addressCborBytes) || result.addressCborBytes instanceof Uint8Array);
9493
});
9594
});
9695

@@ -102,19 +101,6 @@ describe('Cip8Message', function () {
102101
should.exist(payload);
103102
should.ok(Buffer.isBuffer(payload));
104103
});
105-
106-
it('should cache signable payload', async function () {
107-
const message = new Cip8Message(createDefaultMessageOptions());
108-
109-
const payload1 = await message.getSignablePayload();
110-
const payload2 = await message.getSignablePayload();
111-
112-
should.exist(payload1);
113-
should.exist(payload2);
114-
115-
// Payloads should be the same
116-
should.equal(payload1, payload2);
117-
});
118104
});
119105

120106
describe('getBroadcastableSignatures', function () {

0 commit comments

Comments
 (0)