Skip to content

Commit f62f563

Browse files
author
gbena-afk
committed
feat(stealth): add StealthKeyConverter (Ed25519<->Curve25519) and tests (RFC7748 vectors)\n\nCloses: #822
1 parent 9b6c6ae commit f62f563

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

client/lib/stellar-wallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
generateStealthMetaAddress,
66
type StealthMetaAddress,
77
} from '../../shared/src/types/stealth';
8+
import { StealthKeyConverter } from '../../shared/src/crypto/stealth-keys';
89
import { isTorBrowser } from './tor-detection';
910

1011
type WalletInfo = {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import sodium from 'libsodium-wrappers';
2+
import { StealthKeyConverter } from '../crypto/stealth-keys';
3+
4+
function hexToU8(hex: string): Uint8Array {
5+
const clean = hex.replace(/\s+/g, '');
6+
const out = new Uint8Array(clean.length / 2);
7+
for (let i = 0; i < out.length; i++) {
8+
out[i] = parseInt(clean.substr(i * 2, 2), 16);
9+
}
10+
return out;
11+
}
12+
13+
function u8ToHex(u: Uint8Array): string {
14+
return Array.from(u).map(b => b.toString(16).padStart(2, '0')).join('');
15+
}
16+
17+
describe('StealthKeyConverter + RFC7748 vectors', () => {
18+
beforeAll(async () => {
19+
await sodium.ready;
20+
});
21+
22+
test('RFC7748 X25519 Alice/Bob vector', async () => {
23+
const aHex = '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a';
24+
const bHex = '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb';
25+
const Aexp = '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a';
26+
const Bexp = 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f';
27+
const Kexp = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742';
28+
29+
const a = hexToU8(aHex);
30+
const b = hexToU8(bHex);
31+
const basepoint = new Uint8Array(32);
32+
basepoint[0] = 9;
33+
34+
const A = sodium.crypto_scalarmult(a, basepoint);
35+
const B = sodium.crypto_scalarmult(b, basepoint);
36+
37+
expect(u8ToHex(A)).toBe(Aexp);
38+
expect(u8ToHex(B)).toBe(Bexp);
39+
40+
const K1 = sodium.crypto_scalarmult(a, B);
41+
const K2 = sodium.crypto_scalarmult(b, A);
42+
43+
expect(u8ToHex(K1)).toBe(Kexp);
44+
expect(u8ToHex(K2)).toBe(Kexp);
45+
});
46+
47+
test('Ed25519 -> Curve25519 conversion round-trip (shared secret equality)', async () => {
48+
const seedA = new Uint8Array(32);
49+
const seedB = new Uint8Array(32);
50+
for (let i = 0; i < 32; i++) {
51+
seedA[i] = i;
52+
seedB[i] = 255 - i;
53+
}
54+
55+
const kpA = sodium.crypto_sign_seed_keypair(seedA);
56+
const kpB = sodium.crypto_sign_seed_keypair(seedB);
57+
58+
const curveSkA = await StealthKeyConverter.ed25519SecretToCurve25519(kpA.privateKey);
59+
const curvePkA = await StealthKeyConverter.ed25519PublicKeyToCurve25519(kpA.publicKey);
60+
61+
const curveSkB = await StealthKeyConverter.ed25519SecretToCurve25519(kpB.privateKey);
62+
const curvePkB = await StealthKeyConverter.ed25519PublicKeyToCurve25519(kpB.publicKey);
63+
64+
const shared1 = await StealthKeyConverter.deriveSharedSecret(curveSkA, curvePkB);
65+
const shared2 = await StealthKeyConverter.deriveSharedSecret(curveSkB, curvePkA);
66+
67+
expect(u8ToHex(shared1)).toBe(u8ToHex(shared2));
68+
expect(u8ToHex(shared1)).not.toBe('0000000000000000000000000000000000000000000000000000000000000000');
69+
});
70+
});

shared/src/crypto/stealth-keys.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,56 @@
1+
import sodium from 'libsodium-wrappers';
2+
3+
export class StealthKeyConverter {
4+
/** Ensure libsodium is initialized. */
5+
private static async ready() {
6+
if (!(sodium as any).ready) {
7+
await (sodium as any).ready;
8+
} else {
9+
await sodium.ready;
10+
}
11+
}
12+
13+
/**
14+
* Convert an Ed25519 public key (32 bytes) to a Curve25519 public key (32 bytes).
15+
*/
16+
static async ed25519PublicKeyToCurve25519(edPublicKey: Uint8Array): Promise<Uint8Array> {
17+
await this.ready();
18+
return sodium.crypto_sign_ed25519_pk_to_curve25519(edPublicKey);
19+
}
20+
21+
/**
22+
* Convert an Ed25519 secret seed (32 bytes) or full secret key (64 bytes)
23+
* to a Curve25519 secret key (32 bytes).
24+
* If a 32-byte seed is provided, it will be expanded to the full Ed25519
25+
* secret key using `crypto_sign_seed_keypair` first.
26+
*/
27+
static async ed25519SecretToCurve25519(edSecret: Uint8Array): Promise<Uint8Array> {
28+
await this.ready();
29+
30+
let edSecretFull: Uint8Array;
31+
if (edSecret.length === 32) {
32+
const kp = sodium.crypto_sign_seed_keypair(edSecret);
33+
edSecretFull = kp.privateKey;
34+
} else if (edSecret.length === 64) {
35+
edSecretFull = edSecret;
36+
} else {
37+
throw new Error('edSecret must be 32-byte seed or 64-byte secret key');
38+
}
39+
40+
return sodium.crypto_sign_ed25519_sk_to_curve25519(edSecretFull);
41+
}
42+
43+
/**
44+
* Compute the ECDH shared secret using Curve25519 scalar multiplication.
45+
* Returns 32-byte shared secret.
46+
*/
47+
static async deriveSharedSecret(curve25519Secret: Uint8Array, curve25519Public: Uint8Array): Promise<Uint8Array> {
48+
await this.ready();
49+
return sodium.crypto_scalarmult(curve25519Secret, curve25519Public);
50+
}
51+
}
52+
53+
export default StealthKeyConverter;
154
import { edwardsToMontgomeryPub, edwardsToMontgomeryPriv } from '@noble/curves/ed25519';
255

356
/**

shared/tests/stealth-keys.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import sodium from 'libsodium-wrappers';
2+
import { StealthKeyConverter } from '../src/crypto/stealth-keys';
3+
4+
function hexToU8(hex: string): Uint8Array {
5+
const clean = hex.replace(/\s+/g, '');
6+
const out = new Uint8Array(clean.length / 2);
7+
for (let i = 0; i < out.length; i++) {
8+
out[i] = parseInt(clean.substr(i * 2, 2), 16);
9+
}
10+
return out;
11+
}
12+
13+
function u8ToHex(u: Uint8Array): string {
14+
return Array.from(u).map(b => b.toString(16).padStart(2, '0')).join('');
15+
}
16+
17+
describe('StealthKeyConverter + RFC7748 vectors', () => {
18+
beforeAll(async () => {
19+
await sodium.ready;
20+
});
21+
22+
test('RFC7748 X25519 Alice/Bob vector', async () => {
23+
const aHex = '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a';
24+
const bHex = '5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb';
25+
const Aexp = '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a';
26+
const Bexp = 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f';
27+
const Kexp = '4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742';
28+
29+
const a = hexToU8(aHex);
30+
const b = hexToU8(bHex);
31+
const basepoint = new Uint8Array(32);
32+
basepoint[0] = 9;
33+
34+
const A = sodium.crypto_scalarmult(a, basepoint);
35+
const B = sodium.crypto_scalarmult(b, basepoint);
36+
37+
expect(u8ToHex(A)).toBe(Aexp);
38+
expect(u8ToHex(B)).toBe(Bexp);
39+
40+
const K1 = sodium.crypto_scalarmult(a, B);
41+
const K2 = sodium.crypto_scalarmult(b, A);
42+
43+
expect(u8ToHex(K1)).toBe(Kexp);
44+
expect(u8ToHex(K2)).toBe(Kexp);
45+
});
46+
47+
test('Ed25519 -> Curve25519 conversion round-trip (shared secret equality)', async () => {
48+
// Deterministic seeds for reproducibility
49+
const seedA = new Uint8Array(32);
50+
const seedB = new Uint8Array(32);
51+
for (let i = 0; i < 32; i++) {
52+
seedA[i] = i;
53+
seedB[i] = 255 - i;
54+
}
55+
56+
// Create Ed25519 keypairs
57+
const kpA = sodium.crypto_sign_seed_keypair(seedA);
58+
const kpB = sodium.crypto_sign_seed_keypair(seedB);
59+
60+
// Convert to Curve25519 keys
61+
const curveSkA = await StealthKeyConverter.ed25519SecretToCurve25519(kpA.privateKey);
62+
const curvePkA = await StealthKeyConverter.ed25519PublicKeyToCurve25519(kpA.publicKey);
63+
64+
const curveSkB = await StealthKeyConverter.ed25519SecretToCurve25519(kpB.privateKey);
65+
const curvePkB = await StealthKeyConverter.ed25519PublicKeyToCurve25519(kpB.publicKey);
66+
67+
const shared1 = await StealthKeyConverter.deriveSharedSecret(curveSkA, curvePkB);
68+
const shared2 = await StealthKeyConverter.deriveSharedSecret(curveSkB, curvePkA);
69+
70+
expect(u8ToHex(shared1)).toBe(u8ToHex(shared2));
71+
// Also ensure non-zero
72+
expect(u8ToHex(shared1)).not.toBe('0000000000000000000000000000000000000000000000000000000000000000');
73+
});
74+
});

0 commit comments

Comments
 (0)