diff --git a/.changeset/brave-foxes-swim.md b/.changeset/brave-foxes-swim.md new file mode 100644 index 00000000..065aaf75 --- /dev/null +++ b/.changeset/brave-foxes-swim.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Added `Ed25519.toX25519PublicKey` and `Ed25519.toX25519PrivateKey` for converting Ed25519 keys to X25519 keys. Useful for performing X25519 Diffie-Hellman key exchange using an Ed25519 signing key pair. diff --git a/src/core/Ed25519.ts b/src/core/Ed25519.ts index 7a9eebac..7fc69443 100644 --- a/src/core/Ed25519.ts +++ b/src/core/Ed25519.ts @@ -1,4 +1,8 @@ -import { ed25519 } from '@noble/curves/ed25519' +import { + ed25519, + edwardsToMontgomeryPriv, + edwardsToMontgomeryPub, +} from '@noble/curves/ed25519' import * as Bytes from './Bytes.js' import type * as Errors from './Errors.js' import * as Hex from './Hex.js' @@ -235,3 +239,101 @@ export declare namespace verify { type ErrorType = Bytes.from.ErrorType | Errors.GlobalErrorType } + +/** + * Converts an Ed25519 public key to an X25519 public key. + * + * This is useful for performing X25519 Diffie-Hellman key exchange + * using an Ed25519 signing key pair. + * + * @example + * ```ts twoslash + * import { Ed25519, X25519 } from 'ox' + * + * const { privateKey, publicKey } = Ed25519.createKeyPair() + * + * const x25519PublicKey = Ed25519.toX25519PublicKey({ publicKey }) + * ``` + * + * @param options - The options. + * @returns The X25519 public key. + */ +export function toX25519PublicKey( + options: toX25519PublicKey.Options, +): toX25519PublicKey.ReturnType { + const { as = 'Hex', publicKey } = options + const publicKeyBytes = Bytes.from(publicKey) + const x25519PublicKeyBytes = edwardsToMontgomeryPub(publicKeyBytes) + if (as === 'Hex') return Hex.fromBytes(x25519PublicKeyBytes) as never + return x25519PublicKeyBytes as never +} + +export declare namespace toX25519PublicKey { + type Options = { + /** + * Format of the returned public key. + * @default 'Hex' + */ + as?: as | 'Hex' | 'Bytes' | undefined + /** Ed25519 public key to convert. */ + publicKey: Hex.Hex | Bytes.Bytes + } + + type ReturnType = + | (as extends 'Bytes' ? Bytes.Bytes : never) + | (as extends 'Hex' ? Hex.Hex : never) + + type ErrorType = + | Bytes.from.ErrorType + | Hex.fromBytes.ErrorType + | Errors.GlobalErrorType +} + +/** + * Converts an Ed25519 private key to an X25519 private key. + * + * This is useful for performing X25519 Diffie-Hellman key exchange + * using an Ed25519 signing key pair. + * + * @example + * ```ts twoslash + * import { Ed25519, X25519 } from 'ox' + * + * const { privateKey, publicKey } = Ed25519.createKeyPair() + * + * const x25519PrivateKey = Ed25519.toX25519PrivateKey({ privateKey }) + * ``` + * + * @param options - The options. + * @returns The X25519 private key. + */ +export function toX25519PrivateKey( + options: toX25519PrivateKey.Options, +): toX25519PrivateKey.ReturnType { + const { as = 'Hex', privateKey } = options + const privateKeyBytes = Bytes.from(privateKey) + const x25519PrivateKeyBytes = edwardsToMontgomeryPriv(privateKeyBytes) + if (as === 'Hex') return Hex.fromBytes(x25519PrivateKeyBytes) as never + return x25519PrivateKeyBytes as never +} + +export declare namespace toX25519PrivateKey { + type Options = { + /** + * Format of the returned private key. + * @default 'Hex' + */ + as?: as | 'Hex' | 'Bytes' | undefined + /** Ed25519 private key to convert. */ + privateKey: Hex.Hex | Bytes.Bytes + } + + type ReturnType = + | (as extends 'Bytes' ? Bytes.Bytes : never) + | (as extends 'Hex' ? Hex.Hex : never) + + type ErrorType = + | Bytes.from.ErrorType + | Hex.fromBytes.ErrorType + | Errors.GlobalErrorType +} diff --git a/src/core/_test/Ed25519.test.ts b/src/core/_test/Ed25519.test.ts index 4b6f7a15..7eaa9ee9 100644 --- a/src/core/_test/Ed25519.test.ts +++ b/src/core/_test/Ed25519.test.ts @@ -1,4 +1,4 @@ -import { Bytes, Ed25519, Hex } from 'ox' +import { Bytes, Ed25519, Hex, X25519 } from 'ox' import { describe, expect, test } from 'vitest' describe('createKeyPair', () => { @@ -325,6 +325,109 @@ describe('verify', () => { }) }) +describe('toX25519PublicKey', () => { + test('default (Hex)', () => { + const { publicKey } = Ed25519.createKeyPair() + const x25519PublicKey = Ed25519.toX25519PublicKey({ publicKey }) + expect(typeof x25519PublicKey).toBe('string') + expect(x25519PublicKey).toMatch(/^0x[0-9a-fA-F]{64}$/) + }) + + test('with as: Bytes', () => { + const { publicKey } = Ed25519.createKeyPair() + const x25519PublicKey = Ed25519.toX25519PublicKey({ + publicKey, + as: 'Bytes', + }) + expect(x25519PublicKey).toBeInstanceOf(Uint8Array) + expect(x25519PublicKey).toHaveLength(32) + }) + + test('with Bytes public key', () => { + const { publicKey } = Ed25519.createKeyPair({ as: 'Bytes' }) + const x25519PublicKey = Ed25519.toX25519PublicKey({ publicKey }) + expect(typeof x25519PublicKey).toBe('string') + expect(x25519PublicKey).toMatch(/^0x[0-9a-fA-F]{64}$/) + }) + + test('deterministic', () => { + const { publicKey } = Ed25519.createKeyPair() + const a = Ed25519.toX25519PublicKey({ publicKey }) + const b = Ed25519.toX25519PublicKey({ publicKey }) + expect(a).toBe(b) + }) + + test('different from ed25519 public key', () => { + const { publicKey } = Ed25519.createKeyPair() + const x25519PublicKey = Ed25519.toX25519PublicKey({ publicKey }) + expect(x25519PublicKey).not.toBe(publicKey) + }) +}) + +describe('toX25519PrivateKey', () => { + test('default (Hex)', () => { + const { privateKey } = Ed25519.createKeyPair() + const x25519PrivateKey = Ed25519.toX25519PrivateKey({ privateKey }) + expect(typeof x25519PrivateKey).toBe('string') + expect(x25519PrivateKey).toMatch(/^0x[0-9a-fA-F]{64}$/) + }) + + test('with as: Bytes', () => { + const { privateKey } = Ed25519.createKeyPair() + const x25519PrivateKey = Ed25519.toX25519PrivateKey({ + privateKey, + as: 'Bytes', + }) + expect(x25519PrivateKey).toBeInstanceOf(Uint8Array) + expect(x25519PrivateKey).toHaveLength(32) + }) + + test('with Bytes private key', () => { + const { privateKey } = Ed25519.createKeyPair({ as: 'Bytes' }) + const x25519PrivateKey = Ed25519.toX25519PrivateKey({ privateKey }) + expect(typeof x25519PrivateKey).toBe('string') + expect(x25519PrivateKey).toMatch(/^0x[0-9a-fA-F]{64}$/) + }) + + test('deterministic', () => { + const { privateKey } = Ed25519.createKeyPair() + const a = Ed25519.toX25519PrivateKey({ privateKey }) + const b = Ed25519.toX25519PrivateKey({ privateKey }) + expect(a).toBe(b) + }) +}) + +describe('toX25519 integration', () => { + test('converted keys produce valid X25519 shared secret', () => { + const alice = Ed25519.createKeyPair() + const bob = Ed25519.createKeyPair() + + const aliceX25519Priv = Ed25519.toX25519PrivateKey({ + privateKey: alice.privateKey, + }) + const bobX25519Pub = Ed25519.toX25519PublicKey({ + publicKey: bob.publicKey, + }) + const bobX25519Priv = Ed25519.toX25519PrivateKey({ + privateKey: bob.privateKey, + }) + const aliceX25519Pub = Ed25519.toX25519PublicKey({ + publicKey: alice.publicKey, + }) + + const sharedA = X25519.getSharedSecret({ + privateKey: aliceX25519Priv, + publicKey: bobX25519Pub, + }) + const sharedB = X25519.getSharedSecret({ + privateKey: bobX25519Priv, + publicKey: aliceX25519Pub, + }) + + expect(sharedA).toBe(sharedB) + }) +}) + describe('noble export', () => { test('exports noble curves ed25519', () => { expect(Ed25519.noble).toBeDefined()