Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/extension/src/libs/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
sendToTab,
newAccount,
lock,
getPrivateKey,
} from './internal';
import { handlePersistentEvents } from './external';
import SettingsState from '../settings-state';
Expand Down Expand Up @@ -167,6 +168,8 @@ class BackgroundHandler {
case InternalMethods.getNewAccount:
case InternalMethods.saveNewAccount:
return newAccount(this.#keyring, message);
case InternalMethods.getPrivateKey:
return getPrivateKey(this.#keyring, message);
default:
return Promise.resolve({
error: getCustomError(
Expand Down
28 changes: 28 additions & 0 deletions packages/extension/src/libs/background/internal/get-private-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getCustomError } from '@/libs/error'
import KeyRingBase from '@/libs/keyring/keyring'
import { InternalOnMessageResponse } from '@/types/messenger'
import { EnkryptAccount, RPCRequestType } from '@enkryptcom/types'

const getPrivateKey = async (
keyring: KeyRingBase,
message: RPCRequestType,
): Promise<InternalOnMessageResponse> => {
if (!message.params || message.params.length < 2)
return {
error: getCustomError('background: invalid params for getting private key'),
}
const account = message.params[0] as EnkryptAccount
const password = message.params[1] as string
try {
const privKey = await keyring.getPrivateKey(account, password)
return {
result: JSON.stringify(privKey),
}
Comment on lines +14 to +20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use SignOptions, return plain string, and preserve ProviderError.

  • KeyRingBase.getPrivateKey expects SignOptions, not EnkryptAccount. Cast/validate accordingly to avoid type drift.
  • Return the private key as a plain string; JSON-wrapping forces callers to JSON.parse.
  • Preserve code/message if the thrown error is already a ProviderError.
-import { EnkryptAccount, RPCRequestType } from '@enkryptcom/types'
+import { RPCRequestType, SignOptions } from '@enkryptcom/types'
@@
-  const account = message.params[0] as EnkryptAccount
+  const options = message.params[0] as SignOptions
   const password = message.params[1] as string
   try {
-    const privKey = await keyring.getPrivateKey(account, password)
+    const privKey = await keyring.getPrivateKey(options, password)
     return {
-      result: JSON.stringify(privKey),
+      result: privKey,
     }
   } catch (e: any) {
-    return {
-      error: getCustomError(e.message),
-    }
+    // If it's already a ProviderError, forward it; else wrap it.
+    if (e && typeof e === 'object' && 'code' in e && 'message' in e) {
+      return { error: e }
+    }
+    return { error: getCustomError(e?.message ?? 'unknown error') }
   }

I can also add runtime shape checks to coerce EnkryptAccount into SignOptions if you prefer keeping the UI payload unchanged.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const account = message.params[0] as EnkryptAccount
const password = message.params[1] as string
try {
const privKey = await keyring.getPrivateKey(account, password)
return {
result: JSON.stringify(privKey),
}
import { RPCRequestType, SignOptions } from '@enkryptcom/types'
// …other imports…
const options = message.params[0] as SignOptions
const password = message.params[1] as string
try {
const privKey = await keyring.getPrivateKey(options, password)
return {
result: privKey,
}
} catch (e: any) {
// If it's already a ProviderError, forward it; else wrap it.
if (e && typeof e === 'object' && 'code' in e && 'message' in e) {
return { error: e }
}
return { error: getCustomError(e?.message ?? 'unknown error') }
}
🤖 Prompt for AI Agents
In packages/extension/src/libs/background/internal/get-private-key.ts around
lines 14 to 20, the handler currently passes an EnkryptAccount and JSON-wraps
the private key while losing ProviderError details; change it to
convert/validate the incoming payload into SignOptions (or coerce EnkryptAccount
-> SignOptions with runtime checks), call keyring.getPrivateKey(signOptions,
password) with the correct type, return the private key as a raw string in
result (not JSON.stringify), and when catching errors rethrow/preserve existing
ProviderError instances (preserve their code/message) instead of wrapping or
discarding them.

} catch (e: any) {
return {
error: getCustomError(e.message),
}
}
}

export default getPrivateKey
2 changes: 2 additions & 0 deletions packages/extension/src/libs/background/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import changeNetwork from './change-network';
import sendToTab from './send-to-tab';
import newAccount from './new-account';
import lock from './lock';
import getPrivateKey from './get-private-key';
export {
sign,
getEthereumPubKey,
Expand All @@ -15,4 +16,5 @@ export {
sendToTab,
newAccount,
lock,
getPrivateKey,
};
3 changes: 3 additions & 0 deletions packages/extension/src/libs/keyring/keyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,8 @@ export class KeyRingBase {
deleteAccount(address: string): Promise<void> {
return this.#keyring.deleteAccount(address);
}
getPrivateKey(options: SignOptions, password: string): Promise<string> {
return this.#keyring.getPrivateKey(options, password);
}
}
export default KeyRingBase;
1 change: 1 addition & 0 deletions packages/extension/src/types/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export enum InternalMethods {
getNewAccount = 'enkrypt_getNewAccount',
saveNewAccount = 'enkrypt_saveNewAccount',
changeNetwork = 'enkrypt_changeNetwork',
getPrivateKey = 'enkrypt_getPrivateKey',
}
Comment on lines +38 to 39
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Introducing getPrivateKey: confirm strict gating and scope.
Ensure this internal method is only callable from trusted endpoints, requires an unlocked keyring + fresh user confirmation, and never responds to content scripts.

Suggested checks (background handler):

  • Validate sender.origin against allowlist.
  • Require re-auth with password/PIN per invocation.
  • Rate-limit and audit-log export attempts.
  • Return ephemeral data (avoid storing in Redux/state), disable clipboard auto-copy, and scrub from memory/UI ASAP.

You can locate uses with:

Optional typing tweak:

  • Consider typing result as a branded Hex type (e.g., HexString) instead of plain string in InternalOnMessageResponse.

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'InternalMethods\.(getPrivateKey)|["'\'']enkrypt_getPrivateKey["'\'']'

Length of output: 2027


Enforce strict access controls for getPrivateKey

  • In packages/extension/src/libs/background/index.ts, don’t call getPrivateKey unconditionally—validate sender.origin against your allowlist and require a fresh password/PIN unlock of the keyring per request.
  • Rate-limit and audit-log each export; return the key as ephemeral data (no Redux/UI storage), disable any auto-copy, and scrub it from memory/UI immediately.
  • (Optional) Brand the return type as HexString instead of a raw string in InternalOnMessageResponse.
🤖 Prompt for AI Agents
In packages/extension/src/types/messenger.ts around lines 38-39, the
getPrivateKey message is defined but background handlers currently call it
unconditionally; update the background handler in
packages/extension/src/libs/background/index.ts to enforce strict controls:
validate sender.origin against a configured allowlist before proceeding, require
an explicit per-request keyring unlock (password/PIN prompt) and fail if not
provided, apply rate-limiting and write an audit log entry for each export
attempt, return the key as ephemeral data (preferably branded as HexString in
InternalOnMessageResponse) without persisting to Redux/UI or auto-copying, and
immediately scrub the key from memory/UI after use.

export interface SendMessage {
[key: string]: any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,28 @@
>
<delete-icon /><span class="delete">Delete</span>
</a>
<a
v-if="exportable"
class="accounts-item__edit-item"
@click.stop="$emit('action:export')"
>
<view-icon /><span class="export">Export</span>
</a>
</div>
</template>

<script setup lang="ts">
import EditIcon from '@action/icons/actions/edit.vue';
import DeleteIcon from '@action/icons/actions/delete.vue';
import ViewIcon from '@action/icons/actions/view.vue';
defineEmits<{
(e: 'action:rename'): void;
(e: 'action:delete'): void;
(e: 'action:export'): void;
}>();
defineProps({
deletable: Boolean,
exportable: Boolean,
});
</script>

Expand Down Expand Up @@ -77,6 +87,10 @@ defineProps({
&.delete {
color: @error;
}

&.export {
color: @grayPrimary;
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
v-if="openEdit"
ref="dropdown"
:deletable="deletable"
:exportable="exportable"
v-bind="$attrs"
/>
</div>
Expand Down Expand Up @@ -78,6 +79,7 @@ defineProps({
active: Boolean,
showEdit: Boolean,
deletable: Boolean,
exportable: Boolean,
});
const toggleEdit = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<template>
<app-dialog v-model="model" width="344px" is-centered>
<div class="export-account-form">
<h3>Export private key</h3>

<span v-show="!isDone">
<h4>Enter Extension password</h4>

<base-input
type="password"
placeholder="Extension Password"
class="export-account-form__input"
:value="keyringPassword"
@update:value="updateKeyringPassword"
@keyup.enter="showAction"
/>

<p v-show="keyringError" class="export-account-form__error">
Invalid Keyring password
</p>

<base-button
class="export-account-form__button"
title="Show"
:click="showAction"
:disabled="isLoading"
/>
</span>

<div v-if="isDone" class="privkey">
<p class="warning">
⚠️ Keep your private key secure. Never share it with anyone.
</p>
<p class="title">Private Key:</p>
<span class="word">{{ privKey }}</span>
</div>
</div>
</app-dialog>
</template>

<script setup lang="ts">
import { PropType, ref, onMounted, computed } from 'vue';
import AppDialog from '@action/components/app-dialog/index.vue';
import BaseButton from '@action/components/base-button/index.vue';
import BaseInput from '@action/components/base-input/index.vue';
import { NodeType } from '@/types/provider';
import { EnkryptAccount } from '@enkryptcom/types';
import KeyRingBase from '@/libs/keyring/keyring';
import BackupState from '@/libs/backup-state';
import { sendToBackgroundFromAction } from '@/libs/messenger/extension';
import { InternalMethods } from '@/types/messenger';
const model = defineModel<boolean>();
const closeWindow = () => {
model.value = false;
};
const keyringError = ref(false);
const isDone = ref(false);
const isLoading = ref(false);
const keyringPassword = ref(__PREFILL_PASSWORD__!);
const privKey = ref('');
const keyringBase = new KeyRingBase();
const props = defineProps({
account: {
type: Object as PropType<EnkryptAccount>,
default: () => ({}),
},
});
const showAction = async () => {
try {
isLoading.value = true;
const res = await sendToBackgroundFromAction({
message: JSON.stringify({
method: InternalMethods.getPrivateKey,
params: [props.account, keyringPassword.value],
}),
});
if (res.error) {
throw res.error;
} else {
privKey.value = JSON.parse(res.result!);
}
isDone.value = true;
} catch (err) {
keyringError.value = true;
}
isLoading.value = false;
};
Comment on lines +53 to +90
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid JSON round-trip and improve error handling UX.

  • Use the plain string result (after backend change).
  • Reset error state before attempting.
  • Provide a generic error message instead of always “Invalid Keyring password”.
 const showAction = async () => {
-  try {
-    isLoading.value = true;
+  try {
+    isLoading.value = true;
+    keyringError.value = false;
+    errorText.value = '';
     const res = await sendToBackgroundFromAction({
       message: JSON.stringify({
         method: InternalMethods.getPrivateKey,
         params: [props.account, keyringPassword.value],
       }),
     });
     if (res.error) {
       throw res.error;
     } else {
-      privKey.value = JSON.parse(res.result!);
+      privKey.value = res.result!;
     }
     isDone.value = true;
   } catch (err) {
-    keyringError.value = true;
+    keyringError.value = true;
+    errorText.value = 'Invalid password or export not allowed for this account.';
   }
   isLoading.value = false;
 };

Add this state in <script setup>:

-const keyringError = ref(false);
+const keyringError = ref(false);
+const errorText = ref('');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const model = defineModel<boolean>();
const closeWindow = () => {
model.value = false;
};
const keyringError = ref(false);
const isDone = ref(false);
const isLoading = ref(false);
const keyringPassword = ref(__PREFILL_PASSWORD__!);
const privKey = ref('');
const keyringBase = new KeyRingBase();
const props = defineProps({
account: {
type: Object as PropType<EnkryptAccount>,
default: () => ({}),
},
});
const showAction = async () => {
try {
isLoading.value = true;
const res = await sendToBackgroundFromAction({
message: JSON.stringify({
method: InternalMethods.getPrivateKey,
params: [props.account, keyringPassword.value],
}),
});
if (res.error) {
throw res.error;
} else {
privKey.value = JSON.parse(res.result!);
}
isDone.value = true;
} catch (err) {
keyringError.value = true;
}
isLoading.value = false;
};
// In <script setup>, add new errorText state next to keyringError
const keyringError = ref(false);
const errorText = ref('');
const isDone = ref(false);
const isLoading = ref(false);
const keyringPassword = ref(__PREFILL_PASSWORD__!);
const privKey = ref('');
const keyringBase = new KeyRingBase();
const showAction = async () => {
try {
isLoading.value = true;
keyringError.value = false;
errorText.value = '';
const res = await sendToBackgroundFromAction({
message: JSON.stringify({
method: InternalMethods.getPrivateKey,
params: [props.account, keyringPassword.value],
}),
});
if (res.error) {
throw res.error;
} else {
// backend now returns a plain string
privKey.value = res.result!;
}
isDone.value = true;
} catch (err) {
keyringError.value = true;
errorText.value = 'Invalid password or export not allowed for this account.';
} finally {
isLoading.value = false;
}
};

const updateKeyringPassword = (password: string) => {
keyringPassword.value = password;
};
</script>

<style lang="less" scoped>
@import '@action/styles/theme.less';
.export-account-form {
padding: 16px;
h3 {
font-style: normal;
font-weight: 700;
font-size: 24px;
line-height: 32px;
color: @primaryLabel;
margin: 0 0 16px 0;
}
&__button {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
width: 100%;
margin-top: 24px;
}
&__error {
width: 100%;
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 20px;
text-align: center;
letter-spacing: 0.25px;
color: @error;
}
.privkey {
width: 100%;
margin-top: 28px;
.title {
font-style: normal;
font-weight: bold;
font-size: 16px;
line-height: 24px;
color: @primaryLabel;
margin-bottom: 6px;
}
.word {
font-style: normal;
font-weight: 400;
font-size: 18px;
line-height: 24px;
color: black;
background: @lightBg;
border: 1px solid rgba(95, 99, 104, 0.1);
box-sizing: border-box;
border-radius: 10px;
padding: 10px 16px;
margin: 0px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: start;
text-wrap: auto;
user-select: all;
line-break: anywhere;
}
.warning {
font-style: normal;
font-weight: 400;
font-size: 16px;
line-height: 24px;
color: @error;
margin: 0 0 12px 0;
}
}
}
</style>
20 changes: 20 additions & 0 deletions packages/extension/src/ui/action/views/accounts/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
:identicon-element="network.identicon"
:show-edit="true"
:deletable="account.walletType !== WalletType.mnemonic"
:exportable="true"
@action:rename="renameAccount(index)"
@action:delete="deleteAccount(index)"
@action:export="exportAccount(index)"
/>

<div v-if="displayInactive.length > 0" class="accounts__info">
Expand Down Expand Up @@ -100,6 +102,13 @@
:account="accountToDelete"
/>

<export-account-form
v-if="isExportAccount"
v-model="isExportAccount"
v-bind="$attrs"
:account="accountToExport"
/>

<import-account
v-bind="$attrs"
v-model="isImportAccount"
Expand All @@ -114,6 +123,7 @@ import AddAccount from '@action/icons/common/add-account.vue';
import AddAccountForm from './components/add-account-form.vue';
import RenameAccountForm from './components/rename-account-form.vue';
import DeleteAccountForm from './components/delete-account-form.vue';
import ExportAccountForm from './components/export-account-form.vue';
import AddHardwareAccount from '@action/icons/actions/add-hardware-account.vue';
import ImportAccountIcon from '@action/icons/actions/import-account-icon.vue';
import ImportAccount from '@action/views/import-account/index.vue';
Expand All @@ -134,6 +144,7 @@ const emit = defineEmits<{
}>();
const isAddAccount = ref(false);
const isRenameAccount = ref(false);
const isExportAccount = ref(false);
const isDeleteAccount = ref(false);
const isImportAccount = ref(false);
const hwWallet = new HWwallets();
Expand All @@ -154,6 +165,7 @@ const props = defineProps({
});
const accountToRename = ref<EnkryptAccount>();
const accountToDelete = ref<EnkryptAccount>();
const accountToExport = ref<EnkryptAccount>();

const close = () => {
props.toggle();
Expand Down Expand Up @@ -185,6 +197,14 @@ const renameAccount = (accountIdx: number) => {
}, 100);
};

const exportAccount = (accountIdx: number) => {
accountToExport.value = props.accountInfo.activeAccounts[accountIdx];
props.toggle();
setTimeout(() => {
isExportAccount.value = true;
}, 100);
};

const deleteAccount = (accountIdx: number) => {
accountToDelete.value = props.accountInfo.activeAccounts[accountIdx];
props.toggle();
Expand Down
24 changes: 24 additions & 0 deletions packages/keyring/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ class KeyRing {
await this.#storage.set(configs.STORAGE_KEYS.KEY_INFO, existingKeys);
}

async getPrivateKey(
options: SignOptions,
keyringPassword: string,
): Promise<string> {
assert(
!Object.values(HWwalletType).includes(
options.walletType as unknown as HWwalletType,
),
Errors.KeyringErrors.CannotUseKeyring,
);
if (options.walletType === WalletType.privkey) {
const privkeys = await this.#getPrivateKeys(keyringPassword);
assert(privkeys[options.pathIndex.toString()], Errors.KeyringErrors.AddressDoesntExists);
return privkeys[options.pathIndex.toString()];
} else {
const mnemonic = await this.#getMnemonic(keyringPassword)
const keypair = await this.#signers[options.signerType].generate(
mnemonic,
pathParser(options.basePath, options.pathIndex, options.signerType),
);
return keypair.privateKey;
}
}

async #getPrivateKeys(
keyringPassword: string,
): Promise<Record<string, string>> {
Expand Down