Skip to content

Commit

Permalink
feat: unify import/export private key format (MystenLabs#15415)
Browse files Browse the repository at this point in the history
## Description 

See details: sui-foundation/sips#15

note: after discussion we decide not to change the sui.keystore file
storage encoding itself, but only change the import and export interface
for sui.keystore CLI. same for import and export for typescript
interface.

`sui keytool convert`: this converts hex, base64 to bech32
`sui keytool import`: can only import as bech32
`sui keytoo export`: new, export as bech32
## Test Plan 

- unit test

- CLI scenario
```
# import bech32 works 
target/debug/sui keytool import suiprivkey1q8typ5p96jtw3s3cqlnxulcg6me26cswjcpa8984z6k4ey4dwefcwlssmpg ed25519
Keys saved as Base64 with 33 bytes `flag || privkey` ($BASE64_STR). 
        To see Bech32 format encoding, use `sui keytool export --address $ADDR` where 
        $ADDR can be found with `sui keytool list`. Or use `sui keytool convert $BASE64_STR`.
╭─────────────────┬──────────────────────────────────────────────────────────────────────╮
│ alias           │                                                                      │
│ suiAddress      │  0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff  │
│ publicBase64Key │  AQMSxGmd92VJuD30t5sAOVMO//UWqx85kbuOMDRrUCzWfw==                    │
│ keyScheme       │  secp256k1                                                           │
│ flag            │  1                                                                   │
│ peerId          │                                                                      │
╰─────────────────┴──────────────────────────────────────────────────────────────────────╯

# import with alias and bech32 works
 target/debug/sui keytool import suiprivkey1q8typ5p96jtw3s3cqlnxulcg6me26cswjcpa8984z6k4ey4dwefcwlssmpg --alias test-pr ed25519
Keys saved as Base64 with 33 bytes `flag || privkey` ($BASE64_STR). 
        To see Bech32 format encoding, use `sui keytool export --address $ADDR` where 
        $ADDR can be found with `sui keytool list`. Or use `sui keytool convert $BASE64_STR`.
╭─────────────────┬──────────────────────────────────────────────────────────────────────╮
│ alias           │                                                                      │
│ suiAddress      │  0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff  │
│ publicBase64Key │  AQMSxGmd92VJuD30t5sAOVMO//UWqx85kbuOMDRrUCzWfw==                    │
│ keyScheme       │  secp256k1                                                           │
│ flag            │  1                                                                   │
│ peerId          │                                                                      │
╰─────────────────┴──────────────────────────────────────────────────────────────────────╯

# import hex does not work
target/debug/sui keytool import 0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff ed25519
Sui Keystore and Sui Wallet no longer support importing 
                    private key as Hex, if you are sure your private key is encoded in Hex, use 
                    `sui keytool convert $HEX` to convert first then import the Bech32 encoded 
                    private key starting with `suiprivkey`.

# export works
target/debug/sui keytool export 0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff
╭────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────╮
│ exportedPrivateKey │  suiprivkey1q8typ5p96jtw3s3cqlnxulcg6me26cswjcpa8984z6k4ey4dwefcwlssmpg                    │
│ key                │ ╭─────────────────┬──────────────────────────────────────────────────────────────────────╮ │
│                    │ │ alias           │                                                                      │ │
│                    │ │ suiAddress      │  0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff  │ │
│                    │ │ publicBase64Key │  AQMSxGmd92VJuD30t5sAOVMO//UWqx85kbuOMDRrUCzWfw==                    │ │
│                    │ │ keyScheme       │  secp256k1                                                           │ │
│                    │ │ flag            │  1                                                                   │ │
│                    │ │ peerId          │                                                                      │ │
│                    │ ╰─────────────────┴──────────────────────────────────────────────────────────────────────╯ │
╰────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────╯

# export alias works
target/debug/sui keytool export --alias nostalgic-hiddenite                                ✔  10119  10:40:40
╭────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────╮
│ exportedPrivateKey │  suiprivkey1q8typ5p96jtw3s3cqlnxulcg6me26cswjcpa8984z6k4ey4dwefcwlssmpg                    │
│ key                │ ╭─────────────────┬──────────────────────────────────────────────────────────────────────╮ │
│                    │ │ alias           │                                                                      │ │
│                    │ │ suiAddress      │  0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff  │ │
│                    │ │ publicBase64Key │  AQMSxGmd92VJuD30t5sAOVMO//UWqx85kbuOMDRrUCzWfw==                    │ │
│                    │ │ keyScheme       │  secp256k1                                                           │ │
│                    │ │ flag            │  1                                                                   │ │
│                    │ │ peerId          │                                                                      │ │
│                    │ ╰─────────────────┴──────────────────────────────────────────────────────────────────────╯ │
╰────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────╯

# convert works

target/debug/sui keytool convert 0x1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff
╭────────────────┬──────────────────────────────────────────────────────────────────────────╮
│ bech32WithFlag │  suiprivkey1qqdc0fe87kyrpkd690lxanwgldy649h69g4mu967z29lacfldz2l766z9q2  │
│ base64WithFlag │  ABuHpyf1iDDZuiv+bs3I+0mqlvoqK74XXhKL/uE/aJX/                            │
│ hexWithoutFlag │  1b87a727f58830d9ba2bfe6ecdc8fb49aa96fa2a2bbe175e128bfee13f6895ff        │
│ scheme         │  ed25519                                                                 │
╰────────────────┴──────────────────────────────────────────────────────────────────────────╯

```

- wallet 

<img width="360" alt="image"
src="https://github.com/MystenLabs/sui/assets/108701016/6f9eb733-ae93-4027-8bc6-913eac2c77af">

<img width="328" alt="image"
src="https://github.com/MystenLabs/sui/assets/108701016/f05877b0-9b76-4be5-8b8e-5f6d22d136b5">

---
If your changes are not user-facing and not a breaking change, you can
skip the following section. Otherwise, please indicate what changed, and
then add to the Release Notes section as highlighted during the release
process.

### Type of Change (Check all that apply)

- [ ] protocol change
- [x] user-visible impact
- [x] breaking change for a client SDKs
- [ ] breaking change for FNs (FN binary must upgrade)
- [ ] breaking change for validators or node operators (must upgrade
binaries)
- [ ] breaking change for on-chain data layout
- [ ] necessitate either a data wipe or data migration

### Release notes

Sui Keystore in CLI no longer support import private key as 32-byte hex
string. It now only supports import or export Bech32 string encoded
33-byte `flag || private_key` starting with `suiprivkey`. See usage for
`sui keytool convert -h` if you would like to see all formatted private
keys. See `sui keytool export -h` if you need to export a private key in
bech32 format. This also matches import and export private key format in
Sui Wallet and SDK. See
[SIP](sui-foundation/sips#15) for more standard
details.

---------

Co-authored-by: stefan-mysten <[email protected]>
Co-authored-by: Pavlos Chrysochoidis <[email protected]>
  • Loading branch information
3 people authored Jan 30, 2024
1 parent 807167b commit a34f1cb
Show file tree
Hide file tree
Showing 42 changed files with 1,016 additions and 433 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-rocks-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mysten/sui.js': patch
---

deprecate ExportedKeypair
5 changes: 5 additions & 0 deletions .changeset/two-olives-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mysten/sui.js': major
---

Use Bech32 instead of Hex for private key encoding for import and export
1 change: 1 addition & 0 deletions apps/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"@sentry/browser": "^7.61.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-persist-client": "^4.29.25",
"bech32": "^2.0.0",
"bignumber.js": "^9.1.1",
"bootstrap-icons": "^1.10.5",
"buffer": "^6.0.3",
Expand Down
3 changes: 1 addition & 2 deletions apps/wallet/src/background/accounts/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { type Serializable } from '_src/shared/cryptography/keystore';
import {
toSerializedSignature,
type ExportedKeypair,
type Keypair,
type SerializedSignature,
} from '@mysten/sui.js/cryptography';
Expand Down Expand Up @@ -186,7 +185,7 @@ export function isSigningAccount(account: any): account is SigningAccount {

export interface KeyPairExportableAccount {
readonly exportableKeyPair: true;
exportKeyPair(password: string): Promise<ExportedKeypair>;
exportKeyPair(password: string): Promise<string>;
}

export function isKeyPairExportableAccount(account: any): account is KeyPairExportableAccount {
Expand Down
18 changes: 10 additions & 8 deletions apps/wallet/src/background/accounts/ImportedAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// SPDX-License-Identifier: Apache-2.0

import { decrypt, encrypt } from '_src/shared/cryptography/keystore';
import { fromExportedKeypair } from '_src/shared/utils/from-exported-keypair';
import { type ExportedKeypair } from '@mysten/sui.js/cryptography';
import {
fromExportedKeypair,
type LegacyExportedKeyPair,
} from '_src/shared/utils/from-exported-keypair';

import {
Account,
Expand All @@ -14,8 +16,8 @@ import {
type SigningAccount,
} from './Account';

type SessionStorageData = { keyPair: ExportedKeypair };
type EncryptedData = { keyPair: ExportedKeypair };
type SessionStorageData = { keyPair: LegacyExportedKeyPair | string };
type EncryptedData = { keyPair: LegacyExportedKeyPair | string };

export interface ImportedAccountSerialized extends SerializedAccount {
type: 'imported';
Expand Down Expand Up @@ -43,7 +45,7 @@ export class ImportedAccount
readonly exportableKeyPair = true;

static async createNew(inputs: {
keyPair: ExportedKeypair;
keyPair: string;
password: string;
}): Promise<Omit<ImportedAccountSerialized, 'id'>> {
const keyPair = fromExportedKeypair(inputs.keyPair);
Expand Down Expand Up @@ -118,16 +120,16 @@ export class ImportedAccount
return this.generateSignature(data, keyPair);
}

async exportKeyPair(password: string): Promise<ExportedKeypair> {
async exportKeyPair(password: string): Promise<string> {
const { encrypted } = await this.getStoredData();
const { keyPair } = await decrypt<EncryptedData>(password, encrypted);
return keyPair;
return fromExportedKeypair(keyPair, true).getSecretKey();
}

async #getKeyPair() {
const ephemeralData = await this.getEphemeralValue();
if (ephemeralData) {
return fromExportedKeypair(ephemeralData.keyPair);
return fromExportedKeypair(ephemeralData.keyPair, true);
}
return null;
}
Expand Down
10 changes: 5 additions & 5 deletions apps/wallet/src/background/accounts/MnemonicAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { fromExportedKeypair } from '_src/shared/utils/from-exported-keypair';
import { type ExportedKeypair, type Keypair } from '@mysten/sui.js/cryptography';
import { type Keypair } from '@mysten/sui.js/cryptography';

import { MnemonicAccountSource } from '../account-sources/MnemonicAccountSource';
import {
Expand Down Expand Up @@ -34,7 +34,7 @@ export function isMnemonicSerializedUiAccount(
return account.type === 'mnemonic-derived';
}

type SessionStorageData = { keyPair: ExportedKeypair };
type SessionStorageData = { keyPair: string };

export class MnemonicAccount
extends Account<MnemonicSerializedAccount, SessionStorageData>
Expand Down Expand Up @@ -93,7 +93,7 @@ export class MnemonicAccount
await mnemonicSource.unlock(password);
}
await this.setEphemeralValue({
keyPair: (await mnemonicSource.deriveKeyPair(derivationPath)).export(),
keyPair: (await mnemonicSource.deriveKeyPair(derivationPath)).getSecretKey(),
});
await this.onUnlocked();
}
Expand Down Expand Up @@ -138,11 +138,11 @@ export class MnemonicAccount
return this.getCachedData().then(({ sourceID }) => sourceID);
}

async exportKeyPair(password: string): Promise<ExportedKeypair> {
async exportKeyPair(password: string): Promise<string> {
const { derivationPath } = await this.getStoredData();
const mnemonicSource = await this.#getMnemonicSource();
await mnemonicSource.unlock(password);
return (await mnemonicSource.deriveKeyPair(derivationPath)).export();
return (await mnemonicSource.deriveKeyPair(derivationPath)).getSecretKey();
}

async #getKeyPair() {
Expand Down
5 changes: 2 additions & 3 deletions apps/wallet/src/background/accounts/zklogin/ZkLoginAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { deobfuscate, obfuscate } from '_src/shared/cryptography/keystore';
import { fromExportedKeypair } from '_src/shared/utils/from-exported-keypair';
import {
toSerializedSignature,
type ExportedKeypair,
type PublicKey,
type SerializedSignature,
} from '@mysten/sui.js/cryptography';
Expand Down Expand Up @@ -38,7 +37,7 @@ function serializeNetwork(network: NetworkEnvType): SerializedNetwork {
}

type CredentialData = {
ephemeralKeyPair: ExportedKeypair;
ephemeralKeyPair: string;
proofs?: PartialZkLoginSignature;
minEpoch: number;
maxEpoch: number;
Expand Down Expand Up @@ -278,7 +277,7 @@ export class ZkLoginAccount
const ephemeralValue = (await this.getEphemeralValue()) || {};
const activeNetwork = await networkEnv.getActiveNetwork();
const credentialsData: CredentialData = {
ephemeralKeyPair: ephemeralKeyPair.export(),
ephemeralKeyPair: ephemeralKeyPair.getSecretKey(),
minEpoch: Number(epoch),
maxEpoch,
network: activeNetwork,
Expand Down
11 changes: 7 additions & 4 deletions apps/wallet/src/background/legacy-accounts/LegacyVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ import {
toEntropy,
validateEntropy,
} from '_shared/utils/bip39';
import { fromExportedKeypair } from '_shared/utils/from-exported-keypair';
import { mnemonicToSeedHex, type ExportedKeypair, type Keypair } from '@mysten/sui.js/cryptography';
import {
fromExportedKeypair,
type LegacyExportedKeyPair,
} from '_shared/utils/from-exported-keypair';
import { mnemonicToSeedHex, type Keypair } from '@mysten/sui.js/cryptography';

import { getFromLocalStorage } from '../storage-utils';

type StoredData = string | { v: 1 | 2; data: string };

type V2DecryptedDataType = {
entropy: string;
importedKeypairs: ExportedKeypair[];
importedKeypairs: LegacyExportedKeyPair[];
qredoTokens?: Record<string, string>;
mnemonicSeedHex?: string;
};
Expand Down Expand Up @@ -53,7 +56,7 @@ export class LegacyVault {
mnemonicSeedHex: storedMnemonicSeedHex,
} = await decrypt<V2DecryptedDataType>(password, data.data);
entropy = toEntropy(entropySerialized);
keypairs = importedKeypairs.map(fromExportedKeypair);
keypairs = importedKeypairs.map((aKeyPair) => fromExportedKeypair(aKeyPair, true));
if (storedTokens) {
qredoTokens = new Map(Object.entries(storedTokens));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async function makeMnemonicAccounts(password: string, vault: LegacyVault) {
async function makeImportedAccounts(password: string, vault: LegacyVault) {
return Promise.all(
vault.importedKeypairs.map((keyPair) =>
ImportedAccount.createNew({ password, keyPair: keyPair.export() }),
ImportedAccount.createNew({ password, keyPair: keyPair.getSecretKey() }),
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type AccountSourceSerializedUI } from '_src/background/account-sources/
import { type SerializedUIAccount } from '_src/background/accounts/Account';
import { type ZkLoginProvider } from '_src/background/accounts/zklogin/providers';
import { type Status } from '_src/background/legacy-accounts/storage-migration';
import { type ExportedKeypair, type SerializedSignature } from '@mysten/sui.js/cryptography';
import { type SerializedSignature } from '@mysten/sui.js/cryptography';

import { isBasePayload } from './BasePayload';
import type { Payload } from './Payload';
Expand All @@ -32,7 +32,7 @@ type MethodPayloads = {
unlockAccountSourceOrAccount: { id: string; password?: string };
createAccounts:
| { type: 'mnemonic-derived'; sourceID: string }
| { type: 'imported'; keyPair: ExportedKeypair; password: string }
| { type: 'imported'; keyPair: string; password: string }
| {
type: 'ledger';
accounts: { publicKey: string; derivationPath: string; address: string }[];
Expand Down Expand Up @@ -61,7 +61,7 @@ type MethodPayloads = {
setAutoLockMinutes: { minutes: number | null };
notifyUserActive: {};
getAccountKeyPair: { accountID: string; password: string };
getAccountKeyPairResponse: { accountID: string; keyPair: ExportedKeypair };
getAccountKeyPairResponse: { accountID: string; keyPair: string };
resetPassword: {
password: string;
recoveryData: PasswordRecoveryData[];
Expand Down
40 changes: 33 additions & 7 deletions apps/wallet/src/shared/utils/from-exported-keypair.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { type ExportedKeypair, type Keypair } from '@mysten/sui.js/cryptography';
import { type Keypair, type SignatureScheme } from '@mysten/sui.js/cryptography';
import {
decodeSuiPrivateKey,
LEGACY_PRIVATE_KEY_SIZE,
PRIVATE_KEY_SIZE,
} from '@mysten/sui.js/cryptography/keypair';
import { Ed25519Keypair } from '@mysten/sui.js/keypairs/ed25519';
import { Secp256k1Keypair } from '@mysten/sui.js/keypairs/secp256k1';
import { Secp256r1Keypair } from '@mysten/sui.js/keypairs/secp256r1';
import { fromB64 } from '@mysten/sui.js/utils';

const PRIVATE_KEY_SIZE = 32;
const LEGACY_PRIVATE_KEY_SIZE = 64;
export function fromExportedKeypair(keypair: ExportedKeypair): Keypair {
const secretKey = fromB64(keypair.privateKey);
/**
* Wallet stored data might contain imported accounts with their keys stored in the previous format.
* Using this type to type-check it.
*/
export type LegacyExportedKeyPair = {
schema: SignatureScheme;
privateKey: string;
};

switch (keypair.schema) {
export function fromExportedKeypair(
secret: LegacyExportedKeyPair | string,
legacySupport = false,
): Keypair {
let schema;
let secretKey;
if (typeof secret === 'object') {
if (!legacySupport) {
throw new Error('Invalid type of secret key. A string value was expected.');
}
secretKey = fromB64(secret.privateKey);
schema = secret.schema;
} else {
const decoded = decodeSuiPrivateKey(secret);
schema = decoded.schema;
secretKey = decoded.secretKey;
}
switch (schema) {
case 'ED25519':
let pureSecretKey = secretKey;
if (secretKey.length === LEGACY_PRIVATE_KEY_SIZE) {
Expand All @@ -25,6 +51,6 @@ export function fromExportedKeypair(keypair: ExportedKeypair): Keypair {
case 'Secp256r1':
return Secp256r1Keypair.fromSecretKey(secretKey);
default:
throw new Error(`Invalid keypair schema ${keypair.schema}`);
throw new Error(`Invalid keypair schema ${schema}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import { type ZkLoginProvider } from '_src/background/accounts/zklogin/providers';
import { type Wallet } from '_src/shared/qredo-api';
import { type ExportedKeypair } from '@mysten/sui.js/cryptography';
import {
createContext,
useCallback,
Expand All @@ -19,7 +18,7 @@ export type AccountsFormValues =
| { type: 'new-mnemonic' }
| { type: 'import-mnemonic'; entropy: string }
| { type: 'mnemonic-derived'; sourceID: string }
| { type: 'imported'; keyPair: ExportedKeypair }
| { type: 'imported'; keyPair: string }
| {
type: 'ledger';
accounts: { publicKey: string; derivationPath: string; address: string }[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { z } from 'zod';
import { privateKeyValidation } from '../../helpers/validation/privateKeyValidation';
import { Form } from '../../shared/forms/Form';
import { TextAreaField } from '../../shared/forms/TextAreaField';
import Alert from '../alert';

const formSchema = z.object({
privateKey: privateKeyValidation,
Expand All @@ -29,12 +30,20 @@ export function ImportPrivateKeyForm({ onSubmit }: ImportPrivateKeyFormProps) {
const {
register,
formState: { isSubmitting, isValid },
watch,
} = form;
const navigate = useNavigate();

const privateKey = watch('privateKey');
const isHexadecimal = isValid && !privateKey.startsWith('suiprivkey');
return (
<Form className="flex flex-col h-full" form={form} onSubmit={onSubmit}>
<Form className="flex flex-col h-full gap-2" form={form} onSubmit={onSubmit}>
<TextAreaField label="Enter Private Key" rows={4} {...register('privateKey')} />
{isHexadecimal ? (
<Alert mode="warning">
Importing Hex encoded Private Key will soon be deprecated, please use Bech32 encoded
private key that starts with "suiprivkey" instead
</Alert>
) : null}
<div className="flex gap-2.5 mt-auto">
<Button variant="outline" size="tall" text="Cancel" onClick={() => navigate(-1)} />
<Button
Expand Down
Loading

0 comments on commit a34f1cb

Please sign in to comment.