From ba75e970907517e818f6ef17a65f65e7d8f05dff Mon Sep 17 00:00:00 2001 From: Tomer Weller Date: Tue, 23 Dec 2025 18:49:56 -0500 Subject: [PATCH] Add SEP-53 arbitrary message signing support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements SEP-53 (Stellar Arbitrary Message Signing) by adding two new methods to the Keypair class: - `signMessage(message)`: Signs an arbitrary message by prepending the SEP-53 prefix "Stellar Signed Message:\n", hashing with SHA-256, and signing with the ed25519 key. - `verifyMessage(message, signature)`: Verifies a SEP-53 signature by recomputing the hash and verifying against the public key. Both methods accept string or Buffer inputs, enabling use cases like: - Proving account ownership - Off-chain attestations - Application-specific authentication Includes comprehensive tests with SEP-53 test vectors and TypeScript type definitions. Closes #827 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/keypair.js | 59 +++++++++++++ test/unit/keypair_test.js | 168 ++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 2 + 3 files changed, 229 insertions(+) diff --git a/src/keypair.js b/src/keypair.js index 8d36b385..bd899f52 100644 --- a/src/keypair.js +++ b/src/keypair.js @@ -7,6 +7,12 @@ import { hash } from './hashing'; import xdr from './xdr'; +/** + * SEP-53 message signing prefix. + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md + */ +const SEP53_MESSAGE_PREFIX = Buffer.from('Stellar Signed Message:\n'); + /** * `Keypair` represents public (and secret) keys of the account. * @@ -272,4 +278,57 @@ export class Keypair { signature }); } + + /** + * Signs an arbitrary message according to SEP-53. + * + * This method prepends the SEP-53 prefix "Stellar Signed Message:\n" to the + * message, hashes the result with SHA-256, and signs the hash. + * + * @param {string|Buffer} message The message to sign (string or Buffer) + * @returns {Buffer} The 64-byte ed25519 signature + * @throws {Error} If no secret key is available + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md + */ + signMessage(message) { + if (!this.canSign()) { + throw new Error('cannot sign: no secret key available'); + } + + const messageHash = calculateMessageHash(message); + return this.sign(messageHash); + } + + /** + * Verifies a SEP-53 signed message. + * + * This method prepends the SEP-53 prefix "Stellar Signed Message:\n" to the + * message, hashes the result with SHA-256, and verifies the signature against + * this hash. + * + * @param {string|Buffer} message The original message that was signed + * @param {Buffer} signature The 64-byte signature to verify + * @returns {boolean} True if the signature is valid, false otherwise + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md + */ + verifyMessage(message, signature) { + const messageHash = calculateMessageHash(message); + return this.verify(messageHash, signature); + } +} + +/** + * Calculate the SHA-256 hash of a message with the SEP-53 prefix. + * + * @param {string|Buffer} message The message to hash + * @returns {Buffer} The SHA-256 hash + * @private + */ +function calculateMessageHash(message) { + const messageBytes = + typeof message === 'string' ? Buffer.from(message, 'utf8') : message; + const payload = Buffer.concat([SEP53_MESSAGE_PREFIX, messageBytes]); + return hash(payload); } diff --git a/test/unit/keypair_test.js b/test/unit/keypair_test.js index 3f9ebd95..12adbfe7 100644 --- a/test/unit/keypair_test.js +++ b/test/unit/keypair_test.js @@ -196,3 +196,171 @@ describe('Keypair.sign*Decorated', function () { }); }); }); + +describe('Keypair.signMessage and Keypair.verifyMessage (SEP-53)', function () { + // Test vectors from SEP-53 specification + // https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md + const SEP53_TEST_PUBLIC_KEY = + 'GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L'; + const SEP53_TEST_SECRET_KEY = + 'SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW'; + + // Test case from SEP-53: message = "Hello, World!" + // Signature in base64 from spec: fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA== + const SEP53_TEST_MESSAGE = 'Hello, World!'; + const SEP53_TEST_SIGNATURE_BASE64 = + 'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=='; + + describe('Keypair.signMessage', function () { + it('signs a message correctly according to SEP-53', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const signature = kp.signMessage(SEP53_TEST_MESSAGE); + + expect(signature.length).to.equal(64); + expect(Buffer.from(signature).toString('base64')).to.equal( + SEP53_TEST_SIGNATURE_BASE64 + ); + }); + + it('signs a message passed as Buffer', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const messageBuffer = Buffer.from(SEP53_TEST_MESSAGE, 'utf8'); + const signature = kp.signMessage(messageBuffer); + + expect(Buffer.from(signature).toString('base64')).to.equal( + SEP53_TEST_SIGNATURE_BASE64 + ); + }); + + it('throws an error when keypair has no secret key', function () { + const kp = StellarBase.Keypair.fromPublicKey(SEP53_TEST_PUBLIC_KEY); + expect(() => kp.signMessage(SEP53_TEST_MESSAGE)).to.throw( + /cannot sign.*no secret key/ + ); + }); + + it('produces consistent signatures for the same message', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const sig1 = kp.signMessage(SEP53_TEST_MESSAGE); + const sig2 = kp.signMessage(SEP53_TEST_MESSAGE); + + expect(Buffer.from(sig1).toString('base64')).to.equal( + Buffer.from(sig2).toString('base64') + ); + }); + + it('produces different signatures for different messages', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const sig1 = kp.signMessage('Message A'); + const sig2 = kp.signMessage('Message B'); + + expect(Buffer.from(sig1).toString('base64')).to.not.equal( + Buffer.from(sig2).toString('base64') + ); + }); + + it('handles empty messages', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const signature = kp.signMessage(''); + + expect(signature.length).to.equal(64); + }); + + it('handles unicode messages correctly', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const unicodeMessage = '🚀 Stellar to the moon! 月へ'; + const signature = kp.signMessage(unicodeMessage); + + expect(signature.length).to.equal(64); + + // Verify it can be verified + expect(kp.verifyMessage(unicodeMessage, signature)).to.be.true; + }); + }); + + describe('Keypair.verifyMessage', function () { + it('verifies a valid SEP-53 signature', function () { + const kp = StellarBase.Keypair.fromPublicKey(SEP53_TEST_PUBLIC_KEY); + const signature = Buffer.from(SEP53_TEST_SIGNATURE_BASE64, 'base64'); + + expect(kp.verifyMessage(SEP53_TEST_MESSAGE, signature)).to.be.true; + }); + + it('verifies a message passed as Buffer', function () { + const kp = StellarBase.Keypair.fromPublicKey(SEP53_TEST_PUBLIC_KEY); + const messageBuffer = Buffer.from(SEP53_TEST_MESSAGE, 'utf8'); + const signature = Buffer.from(SEP53_TEST_SIGNATURE_BASE64, 'base64'); + + expect(kp.verifyMessage(messageBuffer, signature)).to.be.true; + }); + + it('rejects an invalid signature', function () { + const kp = StellarBase.Keypair.fromPublicKey(SEP53_TEST_PUBLIC_KEY); + const invalidSignature = Buffer.alloc(64, 0); + + expect(kp.verifyMessage(SEP53_TEST_MESSAGE, invalidSignature)).to.be + .false; + }); + + it('rejects a signature for a different message', function () { + const kp = StellarBase.Keypair.fromPublicKey(SEP53_TEST_PUBLIC_KEY); + const signature = Buffer.from(SEP53_TEST_SIGNATURE_BASE64, 'base64'); + + expect(kp.verifyMessage('Different message', signature)).to.be.false; + }); + + it('rejects a signature from a different signer', function () { + // Create a different keypair + const differentKp = StellarBase.Keypair.fromSecret( + 'SD7X7LEHBNMUIKQGKPARG5TDJNBHKC346OUARHGZL5ITC6IJPXHILY36' + ); + const signature = Buffer.from(SEP53_TEST_SIGNATURE_BASE64, 'base64'); + + expect(differentKp.verifyMessage(SEP53_TEST_MESSAGE, signature)).to.be + .false; + }); + + it('works with keypair that has secret key', function () { + const kp = StellarBase.Keypair.fromSecret(SEP53_TEST_SECRET_KEY); + const signature = Buffer.from(SEP53_TEST_SIGNATURE_BASE64, 'base64'); + + expect(kp.verifyMessage(SEP53_TEST_MESSAGE, signature)).to.be.true; + }); + }); + + describe('round-trip signing and verification', function () { + it('can sign and verify with the same keypair', function () { + const kp = StellarBase.Keypair.random(); + const message = 'Test message for round-trip'; + const signature = kp.signMessage(message); + + expect(kp.verifyMessage(message, signature)).to.be.true; + }); + + it('can sign and verify with separate keypairs (secret and public)', function () { + const secretKp = StellarBase.Keypair.random(); + const publicKp = StellarBase.Keypair.fromPublicKey(secretKp.publicKey()); + + const message = 'Test message'; + const signature = secretKp.signMessage(message); + + expect(publicKp.verifyMessage(message, signature)).to.be.true; + }); + + it('handles long messages', function () { + const kp = StellarBase.Keypair.random(); + const longMessage = 'A'.repeat(10000); + const signature = kp.signMessage(longMessage); + + expect(kp.verifyMessage(longMessage, signature)).to.be.true; + }); + + it('handles binary data as Buffer', function () { + const kp = StellarBase.Keypair.random(); + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]); + const signature = kp.signMessage(binaryData); + + expect(kp.verifyMessage(binaryData, signature)).to.be.true; + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 70ad904e..79052e6e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -163,6 +163,8 @@ export class Keypair { signPayloadDecorated(data: Buffer): xdr.DecoratedSignature; signatureHint(): Buffer; verify(data: Buffer, signature: Buffer): boolean; + signMessage(message: string | Buffer): Buffer; + verifyMessage(message: string | Buffer, signature: Buffer): boolean; xdrAccountId(): xdr.AccountId; xdrPublicKey(): xdr.PublicKey;