mirror of
https://github.com/alexgo-io/stacks.js.git
synced 2026-04-28 17:25:51 +08:00
80514
package-lock.json
generated
80514
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
||||
"smart-buffer": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stacks/encryption": "^4.2.2",
|
||||
"@types/common-tags": "^1.8.0",
|
||||
"@types/elliptic": "^6.4.12",
|
||||
"@types/jest": "^26.0.22",
|
||||
|
||||
@@ -28,6 +28,7 @@ enum StacksMessageType {
|
||||
LengthPrefixedList,
|
||||
Payload,
|
||||
MessageSignature,
|
||||
StructuredDataSignature,
|
||||
TransactionAuthField,
|
||||
}
|
||||
|
||||
|
||||
97
packages/transactions/src/structuredDataSignature.ts
Normal file
97
packages/transactions/src/structuredDataSignature.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { Buffer } from '@stacks/common';
|
||||
import { ClarityType, ClarityValue, serializeCV } from './clarity';
|
||||
import { StacksMessageType } from './constants';
|
||||
import { signMessageHashRsv, StacksPrivateKey } from './keys';
|
||||
|
||||
// Refer to SIP018 https://github.com/stacksgov/sips/
|
||||
export const STRUCTURED_DATA_PREFIX = Buffer.from('SIP018', 'ascii');
|
||||
|
||||
export function hashStructuredData(structuredData: ClarityValue): Buffer {
|
||||
return Buffer.from(sha256(serializeCV(structuredData)));
|
||||
}
|
||||
|
||||
const hash256BytesLength = 32;
|
||||
|
||||
function isDomain(value: ClarityValue): boolean {
|
||||
if (value.type !== ClarityType.Tuple) return false;
|
||||
// Check that the tuple has at least 'name', 'version' and 'chain-id'
|
||||
if (!['name', 'version', 'chain-id'].every(key => key in value.data)) return false;
|
||||
// Check each key is of the right type
|
||||
if (!['name', 'version'].every(key => value.data[key].type === ClarityType.StringASCII))
|
||||
return false;
|
||||
|
||||
if (value.data['chain-id'].type !== ClarityType.UInt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function encodeStructuredData({
|
||||
message,
|
||||
domain,
|
||||
}: {
|
||||
message: ClarityValue;
|
||||
domain: ClarityValue;
|
||||
}): Buffer {
|
||||
const structuredDataHash: Buffer = hashStructuredData(message);
|
||||
if (!isDomain(domain)) {
|
||||
throw new Error(
|
||||
"domain parameter must be a valid domain of type TupleCV with keys 'name', 'version', 'chain-id' with respective types StringASCII, StringASCII, UInt"
|
||||
);
|
||||
}
|
||||
const domainHash: Buffer = hashStructuredData(domain);
|
||||
|
||||
return Buffer.concat([STRUCTURED_DATA_PREFIX, domainHash, structuredDataHash]);
|
||||
}
|
||||
|
||||
export type DecodedStructuredData = {
|
||||
domainHash: Buffer;
|
||||
messageHash: Buffer;
|
||||
};
|
||||
|
||||
export function decodeStructuredDataSignature(signature: string | Buffer): DecodedStructuredData {
|
||||
const encodedMessageBuffer: Buffer = Buffer.from(signature);
|
||||
const domainHash = encodedMessageBuffer.slice(
|
||||
STRUCTURED_DATA_PREFIX.length,
|
||||
STRUCTURED_DATA_PREFIX.length + hash256BytesLength
|
||||
);
|
||||
const messageHash = encodedMessageBuffer.slice(
|
||||
STRUCTURED_DATA_PREFIX.length + hash256BytesLength
|
||||
);
|
||||
return {
|
||||
domainHash,
|
||||
messageHash,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StructuredDataSignature {
|
||||
readonly type: StacksMessageType.StructuredDataSignature;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a structured message (ClarityValue) and a domain (ClarityValue) using a private key.
|
||||
* The resulting signature along with the original message can be verified using {@link verifyMessageSignature}
|
||||
* @returns A recoverable signature (in RSV order)
|
||||
*/
|
||||
export function signStructuredData({
|
||||
message,
|
||||
domain,
|
||||
privateKey,
|
||||
}: {
|
||||
message: ClarityValue;
|
||||
domain: ClarityValue;
|
||||
privateKey: StacksPrivateKey;
|
||||
}): StructuredDataSignature {
|
||||
const structuredDataHash: string = Buffer.from(
|
||||
sha256(encodeStructuredData({ message, domain }))
|
||||
).toString('hex');
|
||||
|
||||
const { data } = signMessageHashRsv({
|
||||
messageHash: structuredDataHash,
|
||||
privateKey,
|
||||
});
|
||||
return {
|
||||
data,
|
||||
type: StacksMessageType.StructuredDataSignature,
|
||||
};
|
||||
}
|
||||
242
packages/transactions/tests/structuredDataSignature.test.ts
Normal file
242
packages/transactions/tests/structuredDataSignature.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { standardPrincipalCV, stringAsciiCV, trueCV, tupleCV, uintCV } from '../src/clarity';
|
||||
import { createStacksPrivateKey } from '../src/keys';
|
||||
import { verifyMessageSignatureRsv } from '@stacks/encryption';
|
||||
import {
|
||||
decodeStructuredDataSignature,
|
||||
encodeStructuredData,
|
||||
hashStructuredData,
|
||||
signStructuredData,
|
||||
STRUCTURED_DATA_PREFIX,
|
||||
} from '../src/structuredDataSignature';
|
||||
|
||||
const chainIds = {
|
||||
mainnet: 1,
|
||||
testnet: 2147483648,
|
||||
};
|
||||
|
||||
const principal1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5';
|
||||
|
||||
test('prefix buffer', () => {
|
||||
// Refer to SIP018 https://github.com/stacksgov/sips/
|
||||
// "\x53\x49\x50\x30\x31\x38" is "SIP018" in ASCII
|
||||
expect(
|
||||
Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]).equals(STRUCTURED_DATA_PREFIX)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('encodeStructuredData / decodeStructuredDataSignature', () => {
|
||||
const inputs = [
|
||||
{
|
||||
message: tupleCV({
|
||||
amount: uintCV('100'),
|
||||
recipient: standardPrincipalCV(principal1),
|
||||
salt: uintCV('12345'),
|
||||
}),
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('hiro.so'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
}),
|
||||
},
|
||||
{
|
||||
message: trueCV(),
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('hiro.so'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
}),
|
||||
},
|
||||
];
|
||||
test.each(inputs)('encoding / decoding', ({ message, domain }) => {
|
||||
// encode message and domain
|
||||
const domainHash = hashStructuredData(domain);
|
||||
const messageHash = hashStructuredData(message);
|
||||
const encoded = encodeStructuredData({ message, domain });
|
||||
const { domainHash: decodedDomainHash, messageHash: decodedMessageHash } =
|
||||
decodeStructuredDataSignature(encoded);
|
||||
expect(decodedDomainHash).toEqual(domainHash);
|
||||
expect(decodedMessageHash).toEqual(messageHash);
|
||||
});
|
||||
|
||||
test('encoding non ClarityValue domain', () => {
|
||||
const input = {
|
||||
message: tupleCV({
|
||||
amount: uintCV('100'),
|
||||
recipient: standardPrincipalCV(principal1),
|
||||
salt: uintCV('12345'),
|
||||
}),
|
||||
domain: 'string example' as any, // Cast the type to trigger the error
|
||||
};
|
||||
expect(() => hashStructuredData(input.domain)).toThrowError();
|
||||
expect(() => hashStructuredData(input.message)).not.toThrowError();
|
||||
expect(() => encodeStructuredData(input)).toThrowError();
|
||||
});
|
||||
|
||||
const validMessage = tupleCV({
|
||||
amount: uintCV('100'),
|
||||
recipient: standardPrincipalCV(principal1),
|
||||
salt: uintCV('12345'),
|
||||
});
|
||||
const badDomainInputs = [
|
||||
// Missing keys
|
||||
{
|
||||
message: validMessage,
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('hiro.so'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
// 'chain-id': uintCV(chainIds['mainnet']), // Remove the key to trigger an error
|
||||
}),
|
||||
},
|
||||
// Incorrect 'name' type
|
||||
{
|
||||
message: validMessage,
|
||||
domain: tupleCV({
|
||||
name: uintCV(1),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
}),
|
||||
},
|
||||
// Incorrect 'version' type
|
||||
{
|
||||
message: validMessage,
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('myapp'),
|
||||
version: uintCV(1),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
}),
|
||||
},
|
||||
// Incorrect 'chain-id' type
|
||||
{
|
||||
message: validMessage,
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('myapp'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': stringAsciiCV('1'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
test.each(badDomainInputs)('encoding / decoding failures bad domain type', input => {
|
||||
expect(() => hashStructuredData(input.domain)).not.toThrowError();
|
||||
expect(() => hashStructuredData(input.message)).not.toThrowError();
|
||||
expect(() => encodeStructuredData(input)).toThrowError();
|
||||
});
|
||||
|
||||
test('encoding / decoding failures bad message type', () => {
|
||||
const message1 = 'another string message' as any; // Cast the type to trigger the error
|
||||
const domain1 = tupleCV({
|
||||
name: stringAsciiCV('hiro.so'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
});
|
||||
|
||||
expect(() => hashStructuredData(domain1)).not.toThrowError();
|
||||
expect(() => hashStructuredData(message1)).toThrowError(); // Cast the type to throw error
|
||||
expect(() => encodeStructuredData({ message: message1, domain: domain1 })).toThrowError();
|
||||
});
|
||||
|
||||
test('encoding different inputs produces different outputs', () => {
|
||||
const input1 = {
|
||||
message: tupleCV({
|
||||
amount: uintCV('100'),
|
||||
recipient: standardPrincipalCV(principal1),
|
||||
salt: uintCV('12345'),
|
||||
}),
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('docs.stacks.co'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
}),
|
||||
};
|
||||
|
||||
const input2 = {
|
||||
message: tupleCV({
|
||||
amount: uintCV('100'),
|
||||
recipient: standardPrincipalCV(principal1),
|
||||
salt: uintCV('12345'),
|
||||
}),
|
||||
domain: tupleCV({
|
||||
name: stringAsciiCV('hiro.so'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(chainIds['mainnet']),
|
||||
}),
|
||||
};
|
||||
|
||||
const input1Encoded = encodeStructuredData(input1);
|
||||
const input2Encoded = encodeStructuredData(input2);
|
||||
expect(input1Encoded).not.toEqual(input2Encoded);
|
||||
});
|
||||
});
|
||||
|
||||
// See https://github.com/MarvinJanssen/sips/blob/feat/signed-structured-data/sips/sip-018/sip-018-signed-structured-data.md#test-vectors
|
||||
describe('SIP018 test vectors', () => {
|
||||
const inputs = [
|
||||
{
|
||||
input: stringAsciiCV('Hello World'),
|
||||
expected: '5297eef9765c466d945ad1cb2c81b30b9fed6c165575dc9226e9edf78b8cd9e8',
|
||||
},
|
||||
{
|
||||
input: stringAsciiCV(''),
|
||||
expected: '3c8f1b104592e3ebb2b2602b3979a27e77f586fb4c655369fa4eccb6d545a0f8',
|
||||
},
|
||||
{
|
||||
input: tupleCV({
|
||||
name: stringAsciiCV('Test App'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(1),
|
||||
}),
|
||||
expected: '2538b5dc06c5ae2f11549261d7ae174d9f77a55a92b00f330884695497be5065',
|
||||
},
|
||||
];
|
||||
|
||||
test.each(inputs)('Structured data hashing', ({ input, expected }) => {
|
||||
expect(hashStructuredData(input).toString('hex')).toEqual(expected);
|
||||
});
|
||||
|
||||
test('Message hashing', () => {
|
||||
// Using messageHash(CV), which is sha256(Prefix || structuredDataHash(Domain) || structuredDataHash(CV)).
|
||||
const prefix = '534950303138';
|
||||
expect(prefix).toEqual(STRUCTURED_DATA_PREFIX.toString('hex'));
|
||||
const domain = tupleCV({
|
||||
name: stringAsciiCV('Test App'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(1),
|
||||
});
|
||||
|
||||
const message = stringAsciiCV('Hello World');
|
||||
const expectedMessageHash = '1bfdab6d4158313ce34073fbb8d6b0fc32c154d439def12247a0f44bb2225259';
|
||||
expect(Buffer.from(sha256(encodeStructuredData({ message, domain }))).toString('hex')).toEqual(
|
||||
expectedMessageHash
|
||||
);
|
||||
});
|
||||
|
||||
test('Message signing', () => {
|
||||
const privateKeyString = '753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601';
|
||||
const publicKey = '0390a5cac7c33fda49f70bc1b0866fa0ba7a9440d9de647fecb8132ceb76a94dfa';
|
||||
// const address = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM';
|
||||
const domain = tupleCV({
|
||||
name: stringAsciiCV('Test App'),
|
||||
version: stringAsciiCV('1.0.0'),
|
||||
'chain-id': uintCV(1),
|
||||
});
|
||||
const message = stringAsciiCV('Hello World');
|
||||
const messageHash = '1bfdab6d4158313ce34073fbb8d6b0fc32c154d439def12247a0f44bb2225259';
|
||||
const expectedSignature =
|
||||
'8b94e45701d857c9f1d1d70e8b2ca076045dae4920fb0160be0642a68cd78de072ab527b5c5277a593baeb2a8b657c216b99f7abb5d14af35b4bf12ba6460ba401';
|
||||
const privateKey = createStacksPrivateKey(privateKeyString);
|
||||
const computedSignature = signStructuredData({
|
||||
message,
|
||||
domain,
|
||||
privateKey,
|
||||
});
|
||||
expect(computedSignature.data).toEqual(expectedSignature);
|
||||
// Verify signature
|
||||
const isSignatureVerified = verifyMessageSignatureRsv({
|
||||
signature: computedSignature.data,
|
||||
message: Buffer.from(messageHash, 'hex'),
|
||||
publicKey,
|
||||
});
|
||||
expect(isSignatureVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user