diff --git a/.changeset/wicked-toys-hear.md b/.changeset/wicked-toys-hear.md new file mode 100644 index 000000000000..08f7646b8055 --- /dev/null +++ b/.changeset/wicked-toys-hear.md @@ -0,0 +1,14 @@ +--- +'@solana/keys': patch +--- + +Add a `getPublicKeyFromPrivateKey` helper that, given an extractable `CryptoKey` private key, gets the corresponding public key as a `CryptoKey`. + +```ts +import { createPrivateKeyFromBytes, getPublicKeyFromPrivateKey } from '@solana/keys'; + +const privateKey = await createPrivateKeyFromBytes(new Uint8Array([...]), true); + +const publicKey = await getPublicKeyFromPrivateKey(privateKey); +const extractablePublicKey = await getPublicKeyFromPrivateKey(privateKey, true); +``` diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index 6d44e595abb4..a7268b5b4fab 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -89,6 +89,7 @@ export const SOLANA_ERROR__SUBTLE_CRYPTO__EXPORT_FUNCTION_UNIMPLEMENTED = 361000 export const SOLANA_ERROR__SUBTLE_CRYPTO__GENERATE_FUNCTION_UNIMPLEMENTED = 3610004 as const; export const SOLANA_ERROR__SUBTLE_CRYPTO__SIGN_FUNCTION_UNIMPLEMENTED = 3610005 as const; export const SOLANA_ERROR__SUBTLE_CRYPTO__VERIFY_FUNCTION_UNIMPLEMENTED = 3610006 as const; +export const SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY = 3610007 as const; // Crypto-related errors. // Reserve error codes in the range [3611000-3611050]. @@ -470,6 +471,7 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__SIGNER__TRANSACTION_CANNOT_HAVE_MULTIPLE_SENDING_SIGNERS | typeof SOLANA_ERROR__SIGNER__TRANSACTION_SENDING_SIGNER_MISSING | typeof SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED + | typeof SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY | typeof SOLANA_ERROR__SUBTLE_CRYPTO__DIGEST_UNIMPLEMENTED | typeof SOLANA_ERROR__SUBTLE_CRYPTO__DISALLOWED_IN_INSECURE_CONTEXT | typeof SOLANA_ERROR__SUBTLE_CRYPTO__ED25519_ALGORITHM_UNIMPLEMENTED diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index d548200a2fc6..87e5bb159bfd 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -132,6 +132,7 @@ import { SOLANA_ERROR__SIGNER__EXPECTED_TRANSACTION_PARTIAL_SIGNER, SOLANA_ERROR__SIGNER__EXPECTED_TRANSACTION_SENDING_SIGNER, SOLANA_ERROR__SIGNER__EXPECTED_TRANSACTION_SIGNER, + SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY, SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE, SOLANA_ERROR__TRANSACTION__ADDRESS_MISSING, SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION, @@ -527,6 +528,9 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined< [SOLANA_ERROR__SIGNER__EXPECTED_TRANSACTION_SIGNER]: { address: string; }; + [SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY]: { + key: CryptoKey; + }; [SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE]: { value: number; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index 238c74c91528..7dd33fdc5d5f 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -155,6 +155,7 @@ import { SOLANA_ERROR__SIGNER__TRANSACTION_CANNOT_HAVE_MULTIPLE_SENDING_SIGNERS, SOLANA_ERROR__SIGNER__TRANSACTION_SENDING_SIGNER_MISSING, SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, + SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY, SOLANA_ERROR__SUBTLE_CRYPTO__DIGEST_UNIMPLEMENTED, SOLANA_ERROR__SUBTLE_CRYPTO__DISALLOWED_IN_INSECURE_CONTEXT, SOLANA_ERROR__SUBTLE_CRYPTO__ED25519_ALGORITHM_UNIMPLEMENTED, @@ -484,6 +485,7 @@ export const SolanaErrorMessages: Readonly<{ '`ITransactionWithSingleSendingSigner` transaction.', [SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED]: 'Wallet account signers do not support signing multiple messages/transactions in a single operation', + [SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY]: 'Cannot export a non-extractable key.', [SOLANA_ERROR__SUBTLE_CRYPTO__DIGEST_UNIMPLEMENTED]: 'No digest implementation could be found.', [SOLANA_ERROR__SUBTLE_CRYPTO__DISALLOWED_IN_INSECURE_CONTEXT]: 'Cryptographic operations are only allowed in secure browser contexts. Read more ' + diff --git a/packages/keys/README.md b/packages/keys/README.md index 328f2377a0eb..0efece358e2e 100644 --- a/packages/keys/README.md +++ b/packages/keys/README.md @@ -81,6 +81,30 @@ const keypairBytes = new Uint8Array(JSON.parse(keypairFile.toString())); const { privateKey, publicKey } = await createKeyPairFromBytes(keypairBytes); ``` +### `createPrivateKeyFromBytes()` + +Given a private key represented as a 32-bytes `Uint8Array`, creates an Ed25519 private key for use with other methods in this package that accept `CryptoKey` objects. + +```ts +import { createPrivateKeyFromBytes } from '@solana/keys'; + +const privateKey = await createPrivateKeyFromBytes(new Uint8Array([...])); +const extractablePrivateKey = await createPrivateKeyFromBytes(new Uint8Array([...]), true); +``` + +### `getPublicKeyFromPrivateKey()` + +Given an extractable `CryptoKey` private key, gets the corresponding public key as a `CryptoKey`. + +```ts +import { createPrivateKeyFromBytes, getPublicKeyFromPrivateKey } from '@solana/keys'; + +const privateKey = await createPrivateKeyFromBytes(new Uint8Array([...]), true); + +const publicKey = await getPublicKeyFromPrivateKey(privateKey); +const extractablePublicKey = await getPublicKeyFromPrivateKey(privateKey, true); +``` + ### `isSignature()` This is a type guard that accepts a string as input. It will both return `true` if the string conforms to the `Signature` type and will refine the type for use in your program. diff --git a/packages/keys/src/__tests__/public-key-test.ts b/packages/keys/src/__tests__/public-key-test.ts new file mode 100644 index 000000000000..fd36e3c4d4c3 --- /dev/null +++ b/packages/keys/src/__tests__/public-key-test.ts @@ -0,0 +1,56 @@ +import { SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY, SolanaError } from '@solana/errors'; + +import { createPrivateKeyFromBytes } from '../private-key'; +import { getPublicKeyFromPrivateKey } from '../public-key'; + +const MOCK_PRIVATE_KEY_BYTES = new Uint8Array([ + 0xeb, 0xfa, 0x65, 0xeb, 0x93, 0xdc, 0x79, 0x15, 0x7a, 0xba, 0xde, 0xa2, 0xf7, 0x94, 0x37, 0x9d, 0xfc, 0x07, 0x1d, + 0x68, 0x86, 0x87, 0x37, 0x6d, 0xc5, 0xd5, 0xa0, 0x54, 0x12, 0x1d, 0x34, 0x4a, +]); + +const EXPECTED_MOCK_PUBLIC_KEY_BYTES = new Uint8Array([ + 0x1d, 0x0e, 0x93, 0x86, 0x4d, 0xcc, 0x81, 0x5f, 0xc3, 0xf2, 0x86, 0x18, 0x09, 0x11, 0xd0, 0x0a, 0x3f, 0xd2, 0x06, + 0xde, 0x31, 0xa1, 0xc9, 0x42, 0x87, 0xcb, 0x43, 0xf0, 0x5f, 0xc9, 0xf2, 0xb5, +]); + +describe('getPublicKeyFromPrivateKey', () => { + describe('given an extractable private key', () => { + let privateKey: CryptoKey; + beforeEach(async () => { + privateKey = await createPrivateKeyFromBytes(MOCK_PRIVATE_KEY_BYTES, true); + }); + it('gets the associated public key', async () => { + expect.assertions(1); + const publicKey = await getPublicKeyFromPrivateKey(privateKey, true); + const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', publicKey)); + expect(publicKeyBytes).toEqual(EXPECTED_MOCK_PUBLIC_KEY_BYTES); + }); + it('can get an extractable public key', async () => { + expect.assertions(1); + const publicKey = await getPublicKeyFromPrivateKey(privateKey, true); + expect(publicKey.extractable).toBe(true); + }); + it('can get a non-extractable public key', async () => { + expect.assertions(1); + const publicKey = await getPublicKeyFromPrivateKey(privateKey, false); + expect(publicKey.extractable).toBe(false); + }); + it('returns a non-extractable public key by default', async () => { + expect.assertions(1); + const publicKey = await getPublicKeyFromPrivateKey(privateKey); + expect(publicKey.extractable).toBe(false); + }); + }); + describe('given a non-extractable private key', () => { + let privateKey: CryptoKey; + beforeEach(async () => { + privateKey = await createPrivateKeyFromBytes(MOCK_PRIVATE_KEY_BYTES, false); + }); + it('cannot get the associated public key', async () => { + expect.assertions(1); + await expect(() => getPublicKeyFromPrivateKey(privateKey, true)).rejects.toThrow( + new SolanaError(SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY, { key: privateKey }), + ); + }); + }); +}); diff --git a/packages/keys/src/__typetests__/public-key-typetests.ts b/packages/keys/src/__typetests__/public-key-typetests.ts new file mode 100644 index 000000000000..d6403ad67e96 --- /dev/null +++ b/packages/keys/src/__typetests__/public-key-typetests.ts @@ -0,0 +1,5 @@ +import { getPublicKeyFromPrivateKey } from '../public-key'; + +getPublicKeyFromPrivateKey(new CryptoKey()) satisfies Promise; +getPublicKeyFromPrivateKey(new CryptoKey(), true) satisfies Promise; +getPublicKeyFromPrivateKey(new CryptoKey(), false) satisfies Promise; diff --git a/packages/keys/src/public-key.ts b/packages/keys/src/public-key.ts new file mode 100644 index 000000000000..b1a30d904f01 --- /dev/null +++ b/packages/keys/src/public-key.ts @@ -0,0 +1,31 @@ +import { assertKeyExporterIsAvailable } from '@solana/assertions'; +import { SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY, SolanaError } from '@solana/errors'; + +export async function getPublicKeyFromPrivateKey( + privateKey: CryptoKey, + extractable: boolean = false, +): Promise { + assertKeyExporterIsAvailable(); + + if (privateKey.extractable === false) { + throw new SolanaError(SOLANA_ERROR__SUBTLE_CRYPTO__CANNOT_EXPORT_NON_EXTRACTABLE_KEY, { key: privateKey }); + } + + // Export private key. + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + + // Import public key. + return await crypto.subtle.importKey( + 'jwk', + { + crv /* curve */: 'Ed25519', + ext /* extractable */: extractable, + key_ops /* key operations */: ['verify'], + kty /* key type */: 'OKP' /* octet key pair */, + x /* public key x-coordinate */: jwk.x, + }, + 'Ed25519', + extractable, + ['verify'], + ); +}