Release v1.0.10 - Add unlock popup for locked vault
- Add unlock popup window that appears when vault is locked and a NIP-07 request is made (similar to permission prompt popup) - Implement standalone vault unlock logic in background script using Argon2id key derivation and AES-GCM decryption - Queue pending NIP-07 requests while waiting for unlock, process after success - Add unlock.html and unlock.ts for both Chrome and Firefox extensions Files modified: - package.json (version bump to v1.0.10) - projects/chrome/public/unlock.html (new) - projects/chrome/src/unlock.ts (new) - projects/chrome/src/background.ts - projects/chrome/src/background-common.ts - projects/chrome/custom-webpack.config.ts - projects/chrome/tsconfig.app.json - projects/firefox/public/unlock.html (new) - projects/firefox/src/unlock.ts (new) - projects/firefox/src/background.ts - projects/firefox/src/background-common.ts - projects/firefox/custom-webpack.config.ts - projects/firefox/tsconfig.app.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "plebeian-signer",
|
"name": "plebeian-signer",
|
||||||
"version": "v1.0.9",
|
"version": "v1.0.10",
|
||||||
"custom": {
|
"custom": {
|
||||||
"chrome": {
|
"chrome": {
|
||||||
"version": "v1.0.9"
|
"version": "v1.0.10"
|
||||||
},
|
},
|
||||||
"firefox": {
|
"firefox": {
|
||||||
"version": "v1.0.9"
|
"version": "v1.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,5 +22,9 @@ module.exports = {
|
|||||||
import: 'src/options.ts',
|
import: 'src/options.ts',
|
||||||
runtime: false,
|
runtime: false,
|
||||||
},
|
},
|
||||||
|
unlock: {
|
||||||
|
import: 'src/unlock.ts',
|
||||||
|
runtime: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as Configuration;
|
} as Configuration;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
245
projects/chrome/public/unlock.html
Normal file
245
projects/chrome/public/unlock.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Plebeian Signer - Unlock</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||||
|
<script src="scripts.js"></script>
|
||||||
|
<style>
|
||||||
|
/* Prevent white flash on load */
|
||||||
|
html { background-color: #0a0a0a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { background-color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-primary {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--size);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--size) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-frame {
|
||||||
|
border: 2px solid var(--secondary);
|
||||||
|
border-radius: 100%;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-frame img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: var(--background-light);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--size);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--destructive);
|
||||||
|
color: var(--destructive-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--muted);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-text {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-info {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-name {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
|
<span class="brand">Plebeian Signer</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="logo-frame">
|
||||||
|
<img src="logo.svg" height="100" width="100" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hostInfo" class="host-info hidden">
|
||||||
|
<span class="host-name" id="hostSpan"></span><br>
|
||||||
|
is requesting access
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group sam-mt">
|
||||||
|
<input
|
||||||
|
id="passwordInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="vault password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<button id="togglePassword" type="button">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i>
|
||||||
|
<span>Unlock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deriving overlay -->
|
||||||
|
<div id="derivingOverlay" class="deriving-overlay hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="deriving-text">Unlocking vault...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error alert -->
|
||||||
|
<div id="errorAlert" class="alert alert-danger hidden">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<span id="errorMessage">Invalid password</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="unlock.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,16 +6,38 @@ import {
|
|||||||
CryptoHelper,
|
CryptoHelper,
|
||||||
SignerMetaData,
|
SignerMetaData,
|
||||||
Identity_DECRYPTED,
|
Identity_DECRYPTED,
|
||||||
|
Identity_ENCRYPTED,
|
||||||
Nip07Method,
|
Nip07Method,
|
||||||
Nip07MethodPolicy,
|
Nip07MethodPolicy,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
Permission_DECRYPTED,
|
Permission_DECRYPTED,
|
||||||
Permission_ENCRYPTED,
|
Permission_ENCRYPTED,
|
||||||
|
Relay_DECRYPTED,
|
||||||
|
Relay_ENCRYPTED,
|
||||||
|
NwcConnection_DECRYPTED,
|
||||||
|
NwcConnection_ENCRYPTED,
|
||||||
|
CashuMint_DECRYPTED,
|
||||||
|
CashuMint_ENCRYPTED,
|
||||||
|
deriveKeyArgon2,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
||||||
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
// Unlock request/response message types
|
||||||
|
export interface UnlockRequestMessage {
|
||||||
|
type: 'unlock-request';
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockResponseMessage {
|
||||||
|
type: 'unlock-response';
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const debug = function (message: any) {
|
export const debug = function (message: any) {
|
||||||
const dateString = new Date().toISOString();
|
const dateString = new Date().toISOString();
|
||||||
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||||
@@ -372,3 +394,352 @@ const encrypt = async function (
|
|||||||
// v1: Use password with PBKDF2
|
// v1: Use password with PBKDF2
|
||||||
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
|
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Unlock Vault Logic (for background script)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value using AES-GCM with pre-derived key (v2)
|
||||||
|
*/
|
||||||
|
async function decryptV2(
|
||||||
|
encryptedBase64: string,
|
||||||
|
ivBase64: string,
|
||||||
|
keyBase64: string
|
||||||
|
): Promise<string> {
|
||||||
|
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
cipherText
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value using PBKDF2 (v1)
|
||||||
|
*/
|
||||||
|
async function decryptV1(
|
||||||
|
encryptedBase64: string,
|
||||||
|
ivBase64: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic decrypt function that handles both v1 and v2
|
||||||
|
*/
|
||||||
|
async function decryptValue(
|
||||||
|
encrypted: string,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
if (isV2) {
|
||||||
|
return decryptV2(encrypted, iv, keyOrPassword);
|
||||||
|
}
|
||||||
|
return decryptV1(encrypted, iv, keyOrPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse decrypted value to the desired type
|
||||||
|
*/
|
||||||
|
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
|
||||||
|
switch (type) {
|
||||||
|
case 'number':
|
||||||
|
return parseInt(value);
|
||||||
|
case 'boolean':
|
||||||
|
return value === 'true';
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an identity
|
||||||
|
*/
|
||||||
|
async function decryptIdentity(
|
||||||
|
identity: Identity_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<Identity_DECRYPTED> {
|
||||||
|
return {
|
||||||
|
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
|
||||||
|
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
|
||||||
|
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
|
||||||
|
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a permission
|
||||||
|
*/
|
||||||
|
async function decryptPermission(
|
||||||
|
permission: Permission_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<Permission_DECRYPTED> {
|
||||||
|
const decrypted: Permission_DECRYPTED = {
|
||||||
|
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
|
||||||
|
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
|
||||||
|
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
|
||||||
|
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
|
||||||
|
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
|
||||||
|
};
|
||||||
|
if (permission.kind) {
|
||||||
|
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a relay
|
||||||
|
*/
|
||||||
|
async function decryptRelay(
|
||||||
|
relay: Relay_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<Relay_DECRYPTED> {
|
||||||
|
return {
|
||||||
|
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
|
||||||
|
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
|
||||||
|
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
|
||||||
|
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
|
||||||
|
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an NWC connection
|
||||||
|
*/
|
||||||
|
async function decryptNwcConnection(
|
||||||
|
nwc: NwcConnection_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<NwcConnection_DECRYPTED> {
|
||||||
|
const decrypted: NwcConnection_DECRYPTED = {
|
||||||
|
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
|
||||||
|
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
|
||||||
|
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
|
||||||
|
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
|
||||||
|
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
|
||||||
|
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
|
||||||
|
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
|
||||||
|
};
|
||||||
|
if (nwc.lud16) {
|
||||||
|
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
|
||||||
|
}
|
||||||
|
if (nwc.cachedBalance) {
|
||||||
|
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||||
|
}
|
||||||
|
if (nwc.cachedBalanceAt) {
|
||||||
|
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a Cashu mint
|
||||||
|
*/
|
||||||
|
async function decryptCashuMint(
|
||||||
|
mint: CashuMint_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<CashuMint_DECRYPTED> {
|
||||||
|
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
|
||||||
|
const decrypted: CashuMint_DECRYPTED = {
|
||||||
|
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
|
||||||
|
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
|
||||||
|
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
|
||||||
|
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
|
||||||
|
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
|
||||||
|
proofs: JSON.parse(proofsJson),
|
||||||
|
};
|
||||||
|
if (mint.cachedBalance) {
|
||||||
|
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||||
|
}
|
||||||
|
if (mint.cachedBalanceAt) {
|
||||||
|
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an unlock request from the unlock popup
|
||||||
|
*/
|
||||||
|
export async function handleUnlockRequest(
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
debug('handleUnlockRequest: Starting unlock...');
|
||||||
|
|
||||||
|
// Check if already unlocked
|
||||||
|
const existingSession = await getBrowserSessionData();
|
||||||
|
if (existingSession) {
|
||||||
|
debug('handleUnlockRequest: Already unlocked');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sync data
|
||||||
|
const browserSyncData = await getBrowserSyncData();
|
||||||
|
if (!browserSyncData) {
|
||||||
|
return { success: false, error: 'No vault data found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const passwordHash = await CryptoHelper.hash(password);
|
||||||
|
if (passwordHash !== browserSyncData.vaultHash) {
|
||||||
|
return { success: false, error: 'Invalid password' };
|
||||||
|
}
|
||||||
|
debug('handleUnlockRequest: Password verified');
|
||||||
|
|
||||||
|
// Detect vault version
|
||||||
|
const isV2 = !!browserSyncData.salt;
|
||||||
|
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
|
||||||
|
|
||||||
|
let keyOrPassword: string;
|
||||||
|
let vaultKey: string | undefined;
|
||||||
|
let vaultPassword: string | undefined;
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
// v2: Derive key with Argon2id (~3 seconds)
|
||||||
|
debug('handleUnlockRequest: Deriving Argon2id key...');
|
||||||
|
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||||
|
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||||
|
keyOrPassword = vaultKey;
|
||||||
|
debug('handleUnlockRequest: Key derived');
|
||||||
|
} else {
|
||||||
|
// v1: Use password directly
|
||||||
|
vaultPassword = password;
|
||||||
|
keyOrPassword = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt identities
|
||||||
|
debug('handleUnlockRequest: Decrypting identities...');
|
||||||
|
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||||
|
for (const identity of browserSyncData.identities) {
|
||||||
|
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedIdentities.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
|
||||||
|
|
||||||
|
// Decrypt permissions
|
||||||
|
debug('handleUnlockRequest: Decrypting permissions...');
|
||||||
|
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||||
|
for (const permission of browserSyncData.permissions) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedPermissions.push(decrypted);
|
||||||
|
} catch (e) {
|
||||||
|
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
|
||||||
|
|
||||||
|
// Decrypt relays
|
||||||
|
debug('handleUnlockRequest: Decrypting relays...');
|
||||||
|
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||||
|
for (const relay of browserSyncData.relays) {
|
||||||
|
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedRelays.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
|
||||||
|
|
||||||
|
// Decrypt NWC connections
|
||||||
|
debug('handleUnlockRequest: Decrypting NWC connections...');
|
||||||
|
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
|
||||||
|
for (const nwc of browserSyncData.nwcConnections ?? []) {
|
||||||
|
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedNwcConnections.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
|
||||||
|
|
||||||
|
// Decrypt Cashu mints
|
||||||
|
debug('handleUnlockRequest: Decrypting Cashu mints...');
|
||||||
|
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
|
||||||
|
for (const mint of browserSyncData.cashuMints ?? []) {
|
||||||
|
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedCashuMints.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
|
||||||
|
|
||||||
|
// Decrypt selectedIdentityId
|
||||||
|
let decryptedSelectedIdentityId: string | null = null;
|
||||||
|
if (browserSyncData.selectedIdentityId !== null) {
|
||||||
|
decryptedSelectedIdentityId = await decryptValue(
|
||||||
|
browserSyncData.selectedIdentityId,
|
||||||
|
browserSyncData.iv,
|
||||||
|
keyOrPassword,
|
||||||
|
isV2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
|
||||||
|
|
||||||
|
// Build session data
|
||||||
|
const browserSessionData: BrowserSessionData = {
|
||||||
|
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||||
|
vaultKey: isV2 ? vaultKey : undefined,
|
||||||
|
iv: browserSyncData.iv,
|
||||||
|
salt: browserSyncData.salt,
|
||||||
|
permissions: decryptedPermissions,
|
||||||
|
identities: decryptedIdentities,
|
||||||
|
selectedIdentityId: decryptedSelectedIdentityId,
|
||||||
|
relays: decryptedRelays,
|
||||||
|
nwcConnections: decryptedNwcConnections,
|
||||||
|
cashuMints: decryptedCashuMints,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save session data
|
||||||
|
debug('handleUnlockRequest: Saving session data...');
|
||||||
|
await chrome.storage.session.set(browserSessionData);
|
||||||
|
debug('handleUnlockRequest: Unlock complete!');
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
debug(`handleUnlockRequest: Error: ${error.message}`);
|
||||||
|
return { success: false, error: error.message || 'Unlock failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the unlock popup window
|
||||||
|
*/
|
||||||
|
export async function openUnlockPopup(host?: string): Promise<void> {
|
||||||
|
const width = 375;
|
||||||
|
const height = 500;
|
||||||
|
const { top, left } = await getPosition(width, height);
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
let url = `unlock.html?id=${id}`;
|
||||||
|
if (host) {
|
||||||
|
url += `&host=${encodeURIComponent(host)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chrome.windows.create({
|
||||||
|
type: 'popup',
|
||||||
|
url,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ import {
|
|||||||
debug,
|
debug,
|
||||||
getBrowserSessionData,
|
getBrowserSessionData,
|
||||||
getPosition,
|
getPosition,
|
||||||
|
handleUnlockRequest,
|
||||||
nip04Decrypt,
|
nip04Decrypt,
|
||||||
nip04Encrypt,
|
nip04Encrypt,
|
||||||
nip44Decrypt,
|
nip44Decrypt,
|
||||||
nip44Encrypt,
|
nip44Encrypt,
|
||||||
|
openUnlockPopup,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
PromptResponseMessage,
|
PromptResponseMessage,
|
||||||
shouldRecklessModeApprove,
|
shouldRecklessModeApprove,
|
||||||
signEvent,
|
signEvent,
|
||||||
storePermission,
|
storePermission,
|
||||||
|
UnlockRequestMessage,
|
||||||
|
UnlockResponseMessage,
|
||||||
} from './background-common';
|
} from './background-common';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
@@ -33,8 +37,49 @@ const openPrompts = new Map<
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
// Track if unlock popup is already open
|
||||||
|
let unlockPopupOpen = false;
|
||||||
|
|
||||||
|
// Queue of pending NIP-07 requests waiting for unlock
|
||||||
|
const pendingRequests: {
|
||||||
|
request: BackgroundRequestMessage;
|
||||||
|
resolve: (result: any) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||||
debug('Message received');
|
debug('Message received');
|
||||||
|
|
||||||
|
// Handle unlock request from unlock popup
|
||||||
|
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
|
||||||
|
const unlockReq = message as UnlockRequestMessage;
|
||||||
|
debug('Processing unlock request');
|
||||||
|
const result = await handleUnlockRequest(unlockReq.password);
|
||||||
|
const response: UnlockResponseMessage = {
|
||||||
|
type: 'unlock-response',
|
||||||
|
id: unlockReq.id,
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
unlockPopupOpen = false;
|
||||||
|
// Process any pending NIP-07 requests
|
||||||
|
debug(`Processing ${pendingRequests.length} pending requests`);
|
||||||
|
while (pendingRequests.length > 0) {
|
||||||
|
const pending = pendingRequests.shift()!;
|
||||||
|
try {
|
||||||
|
const pendingResult = await processNip07Request(pending.request);
|
||||||
|
pending.resolve(pendingResult);
|
||||||
|
} catch (error) {
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
||||||
debug(request);
|
debug(request);
|
||||||
|
|
||||||
@@ -55,6 +100,32 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
|
|
||||||
const browserSessionData = await getBrowserSessionData();
|
const browserSessionData = await getBrowserSessionData();
|
||||||
|
|
||||||
|
if (!browserSessionData) {
|
||||||
|
// Vault is locked - open unlock popup and queue the request
|
||||||
|
const req = request as BackgroundRequestMessage;
|
||||||
|
debug('Vault locked, opening unlock popup');
|
||||||
|
|
||||||
|
if (!unlockPopupOpen) {
|
||||||
|
unlockPopupOpen = true;
|
||||||
|
await openUnlockPopup(req.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue this request to be processed after unlock
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push({ request: req, resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the NIP-07 request
|
||||||
|
return processNip07Request(request as BackgroundRequestMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a NIP-07 request after vault is unlocked
|
||||||
|
*/
|
||||||
|
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
|
||||||
|
const browserSessionData = await getBrowserSessionData();
|
||||||
|
|
||||||
if (!browserSessionData) {
|
if (!browserSessionData) {
|
||||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||||
}
|
}
|
||||||
@@ -67,8 +138,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
throw new Error('No Nostr identity available at endpoint.');
|
throw new Error('No Nostr identity available at endpoint.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = request as BackgroundRequestMessage;
|
|
||||||
|
|
||||||
// Check reckless mode first
|
// Check reckless mode first
|
||||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||||
debug(`recklessApprove result: ${recklessApprove}`);
|
debug(`recklessApprove result: ${recklessApprove}`);
|
||||||
@@ -212,4 +281,4 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
default:
|
default:
|
||||||
throw new Error(`Not supported request method '${req.method}'.`);
|
throw new Error(`Not supported request method '${req.method}'.`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|||||||
106
projects/chrome/src/unlock.ts
Normal file
106
projects/chrome/src/unlock.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
export interface UnlockRequestMessage {
|
||||||
|
type: 'unlock-request';
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockResponseMessage {
|
||||||
|
type: 'unlock-response';
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const id = params.get('id') as string;
|
||||||
|
const host = params.get('host');
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
|
||||||
|
const togglePasswordBtn = document.getElementById('togglePassword');
|
||||||
|
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
|
||||||
|
const derivingOverlay = document.getElementById('derivingOverlay');
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
const hostInfo = document.getElementById('hostInfo');
|
||||||
|
const hostSpan = document.getElementById('hostSpan');
|
||||||
|
|
||||||
|
// Show host info if available
|
||||||
|
if (host && hostInfo && hostSpan) {
|
||||||
|
hostSpan.innerText = host;
|
||||||
|
hostInfo.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle password visibility
|
||||||
|
togglePasswordBtn?.addEventListener('click', () => {
|
||||||
|
if (passwordInput.type === 'password') {
|
||||||
|
passwordInput.type = 'text';
|
||||||
|
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
|
||||||
|
} else {
|
||||||
|
passwordInput.type = 'password';
|
||||||
|
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable/disable unlock button based on password input
|
||||||
|
passwordInput?.addEventListener('input', () => {
|
||||||
|
unlockBtn.disabled = !passwordInput.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle enter key
|
||||||
|
passwordInput?.addEventListener('keyup', (e) => {
|
||||||
|
if (e.key === 'Enter' && passwordInput.value) {
|
||||||
|
attemptUnlock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unlock button click
|
||||||
|
unlockBtn?.addEventListener('click', attemptUnlock);
|
||||||
|
|
||||||
|
async function attemptUnlock() {
|
||||||
|
if (!passwordInput?.value) return;
|
||||||
|
|
||||||
|
// Show deriving overlay
|
||||||
|
derivingOverlay?.classList.remove('hidden');
|
||||||
|
errorAlert?.classList.add('hidden');
|
||||||
|
|
||||||
|
const message: UnlockRequestMessage = {
|
||||||
|
type: 'unlock-request',
|
||||||
|
id,
|
||||||
|
password: passwordInput.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Success - close the window
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
// Failed - show error
|
||||||
|
derivingOverlay?.classList.add('hidden');
|
||||||
|
showError(response.error || 'Invalid password');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send unlock message:', error);
|
||||||
|
derivingOverlay?.classList.add('hidden');
|
||||||
|
showError('Failed to unlock vault');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message: string) {
|
||||||
|
if (errorAlert && errorMessage) {
|
||||||
|
errorMessage.innerText = message;
|
||||||
|
errorAlert.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
errorAlert.classList.add('hidden');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus password input on load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
passwordInput?.focus();
|
||||||
|
});
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"src/plebian-signer-extension.ts",
|
"src/plebian-signer-extension.ts",
|
||||||
"src/plebian-signer-content-script.ts",
|
"src/plebian-signer-content-script.ts",
|
||||||
"src/prompt.ts",
|
"src/prompt.ts",
|
||||||
"src/options.ts"
|
"src/options.ts",
|
||||||
|
"src/unlock.ts"
|
||||||
],
|
],
|
||||||
"include": ["src/**/*.d.ts"]
|
"include": ["src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,9 @@ module.exports = {
|
|||||||
import: 'src/options.ts',
|
import: 'src/options.ts',
|
||||||
runtime: false,
|
runtime: false,
|
||||||
},
|
},
|
||||||
|
unlock: {
|
||||||
|
import: 'src/unlock.ts',
|
||||||
|
runtime: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as Configuration;
|
} as Configuration;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer",
|
"name": "Plebeian Signer",
|
||||||
"description": "Nostr Identity Manager & Signer",
|
"description": "Nostr Identity Manager & Signer",
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
245
projects/firefox/public/unlock.html
Normal file
245
projects/firefox/public/unlock.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Plebeian Signer - Unlock</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||||
|
<script src="scripts.js"></script>
|
||||||
|
<style>
|
||||||
|
/* Prevent white flash on load */
|
||||||
|
html { background-color: #0a0a0a; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { background-color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-primary {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--size);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--size) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-frame {
|
||||||
|
border: 2px solid var(--secondary);
|
||||||
|
border-radius: 100%;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-frame img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: var(--background-light);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--size);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: var(--destructive);
|
||||||
|
color: var(--destructive-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--muted);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-text {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-info {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-name {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="header">
|
||||||
|
<span class="brand">Plebeian Signer</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="logo-frame">
|
||||||
|
<img src="logo.svg" height="100" width="100" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hostInfo" class="host-info hidden">
|
||||||
|
<span class="host-name" id="hostSpan"></span><br>
|
||||||
|
is requesting access
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group sam-mt">
|
||||||
|
<input
|
||||||
|
id="passwordInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="vault password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<button id="togglePassword" type="button">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i>
|
||||||
|
<span>Unlock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deriving overlay -->
|
||||||
|
<div id="derivingOverlay" class="deriving-overlay hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="deriving-text">Unlocking vault...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error alert -->
|
||||||
|
<div id="errorAlert" class="alert alert-danger hidden">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<span id="errorMessage">Invalid password</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="unlock.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,16 +6,38 @@ import {
|
|||||||
CryptoHelper,
|
CryptoHelper,
|
||||||
SignerMetaData,
|
SignerMetaData,
|
||||||
Identity_DECRYPTED,
|
Identity_DECRYPTED,
|
||||||
|
Identity_ENCRYPTED,
|
||||||
Nip07Method,
|
Nip07Method,
|
||||||
Nip07MethodPolicy,
|
Nip07MethodPolicy,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
Permission_DECRYPTED,
|
Permission_DECRYPTED,
|
||||||
Permission_ENCRYPTED,
|
Permission_ENCRYPTED,
|
||||||
|
Relay_DECRYPTED,
|
||||||
|
Relay_ENCRYPTED,
|
||||||
|
NwcConnection_DECRYPTED,
|
||||||
|
NwcConnection_ENCRYPTED,
|
||||||
|
CashuMint_DECRYPTED,
|
||||||
|
CashuMint_ENCRYPTED,
|
||||||
|
deriveKeyArgon2,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
|
||||||
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
|
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
|
||||||
import browser from 'webextension-polyfill';
|
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
// Unlock request/response message types
|
||||||
|
export interface UnlockRequestMessage {
|
||||||
|
type: 'unlock-request';
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockResponseMessage {
|
||||||
|
type: 'unlock-response';
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const debug = function (message: any) {
|
export const debug = function (message: any) {
|
||||||
const dateString = new Date().toISOString();
|
const dateString = new Date().toISOString();
|
||||||
@@ -96,13 +118,9 @@ export const getBrowserSyncData = async function (): Promise<
|
|||||||
let browserSyncData: BrowserSyncData | undefined;
|
let browserSyncData: BrowserSyncData | undefined;
|
||||||
|
|
||||||
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
|
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
|
||||||
browserSyncData = (await browser.storage.local.get(
|
browserSyncData = (await browser.storage.local.get(null)) as unknown as BrowserSyncData;
|
||||||
null
|
|
||||||
)) as unknown as BrowserSyncData;
|
|
||||||
} else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
} else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||||
browserSyncData = (await browser.storage.sync.get(
|
browserSyncData = (await browser.storage.sync.get(null)) as unknown as BrowserSyncData;
|
||||||
null
|
|
||||||
)) as unknown as BrowserSyncData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return browserSyncData;
|
return browserSyncData;
|
||||||
@@ -377,3 +395,352 @@ const encrypt = async function (
|
|||||||
// v1: Use password with PBKDF2
|
// v1: Use password with PBKDF2
|
||||||
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
|
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Unlock Vault Logic (for background script)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value using AES-GCM with pre-derived key (v2)
|
||||||
|
*/
|
||||||
|
async function decryptV2(
|
||||||
|
encryptedBase64: string,
|
||||||
|
ivBase64: string,
|
||||||
|
keyBase64: string
|
||||||
|
): Promise<string> {
|
||||||
|
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
cipherText
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a value using PBKDF2 (v1)
|
||||||
|
*/
|
||||||
|
async function decryptV1(
|
||||||
|
encryptedBase64: string,
|
||||||
|
ivBase64: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic decrypt function that handles both v1 and v2
|
||||||
|
*/
|
||||||
|
async function decryptValue(
|
||||||
|
encrypted: string,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
if (isV2) {
|
||||||
|
return decryptV2(encrypted, iv, keyOrPassword);
|
||||||
|
}
|
||||||
|
return decryptV1(encrypted, iv, keyOrPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse decrypted value to the desired type
|
||||||
|
*/
|
||||||
|
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
|
||||||
|
switch (type) {
|
||||||
|
case 'number':
|
||||||
|
return parseInt(value);
|
||||||
|
case 'boolean':
|
||||||
|
return value === 'true';
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an identity
|
||||||
|
*/
|
||||||
|
async function decryptIdentity(
|
||||||
|
identity: Identity_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<Identity_DECRYPTED> {
|
||||||
|
return {
|
||||||
|
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
|
||||||
|
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
|
||||||
|
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
|
||||||
|
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a permission
|
||||||
|
*/
|
||||||
|
async function decryptPermission(
|
||||||
|
permission: Permission_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<Permission_DECRYPTED> {
|
||||||
|
const decrypted: Permission_DECRYPTED = {
|
||||||
|
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
|
||||||
|
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
|
||||||
|
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
|
||||||
|
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
|
||||||
|
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
|
||||||
|
};
|
||||||
|
if (permission.kind) {
|
||||||
|
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a relay
|
||||||
|
*/
|
||||||
|
async function decryptRelay(
|
||||||
|
relay: Relay_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<Relay_DECRYPTED> {
|
||||||
|
return {
|
||||||
|
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
|
||||||
|
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
|
||||||
|
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
|
||||||
|
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
|
||||||
|
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an NWC connection
|
||||||
|
*/
|
||||||
|
async function decryptNwcConnection(
|
||||||
|
nwc: NwcConnection_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<NwcConnection_DECRYPTED> {
|
||||||
|
const decrypted: NwcConnection_DECRYPTED = {
|
||||||
|
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
|
||||||
|
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
|
||||||
|
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
|
||||||
|
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
|
||||||
|
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
|
||||||
|
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
|
||||||
|
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
|
||||||
|
};
|
||||||
|
if (nwc.lud16) {
|
||||||
|
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
|
||||||
|
}
|
||||||
|
if (nwc.cachedBalance) {
|
||||||
|
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||||
|
}
|
||||||
|
if (nwc.cachedBalanceAt) {
|
||||||
|
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a Cashu mint
|
||||||
|
*/
|
||||||
|
async function decryptCashuMint(
|
||||||
|
mint: CashuMint_ENCRYPTED,
|
||||||
|
iv: string,
|
||||||
|
keyOrPassword: string,
|
||||||
|
isV2: boolean
|
||||||
|
): Promise<CashuMint_DECRYPTED> {
|
||||||
|
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
|
||||||
|
const decrypted: CashuMint_DECRYPTED = {
|
||||||
|
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
|
||||||
|
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
|
||||||
|
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
|
||||||
|
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
|
||||||
|
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
|
||||||
|
proofs: JSON.parse(proofsJson),
|
||||||
|
};
|
||||||
|
if (mint.cachedBalance) {
|
||||||
|
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||||
|
}
|
||||||
|
if (mint.cachedBalanceAt) {
|
||||||
|
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an unlock request from the unlock popup
|
||||||
|
*/
|
||||||
|
export async function handleUnlockRequest(
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
debug('handleUnlockRequest: Starting unlock...');
|
||||||
|
|
||||||
|
// Check if already unlocked
|
||||||
|
const existingSession = await getBrowserSessionData();
|
||||||
|
if (existingSession) {
|
||||||
|
debug('handleUnlockRequest: Already unlocked');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sync data
|
||||||
|
const browserSyncData = await getBrowserSyncData();
|
||||||
|
if (!browserSyncData) {
|
||||||
|
return { success: false, error: 'No vault data found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const passwordHash = await CryptoHelper.hash(password);
|
||||||
|
if (passwordHash !== browserSyncData.vaultHash) {
|
||||||
|
return { success: false, error: 'Invalid password' };
|
||||||
|
}
|
||||||
|
debug('handleUnlockRequest: Password verified');
|
||||||
|
|
||||||
|
// Detect vault version
|
||||||
|
const isV2 = !!browserSyncData.salt;
|
||||||
|
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
|
||||||
|
|
||||||
|
let keyOrPassword: string;
|
||||||
|
let vaultKey: string | undefined;
|
||||||
|
let vaultPassword: string | undefined;
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
// v2: Derive key with Argon2id (~3 seconds)
|
||||||
|
debug('handleUnlockRequest: Deriving Argon2id key...');
|
||||||
|
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||||
|
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||||
|
keyOrPassword = vaultKey;
|
||||||
|
debug('handleUnlockRequest: Key derived');
|
||||||
|
} else {
|
||||||
|
// v1: Use password directly
|
||||||
|
vaultPassword = password;
|
||||||
|
keyOrPassword = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt identities
|
||||||
|
debug('handleUnlockRequest: Decrypting identities...');
|
||||||
|
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||||
|
for (const identity of browserSyncData.identities) {
|
||||||
|
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedIdentities.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
|
||||||
|
|
||||||
|
// Decrypt permissions
|
||||||
|
debug('handleUnlockRequest: Decrypting permissions...');
|
||||||
|
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||||
|
for (const permission of browserSyncData.permissions) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedPermissions.push(decrypted);
|
||||||
|
} catch (e) {
|
||||||
|
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
|
||||||
|
|
||||||
|
// Decrypt relays
|
||||||
|
debug('handleUnlockRequest: Decrypting relays...');
|
||||||
|
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||||
|
for (const relay of browserSyncData.relays) {
|
||||||
|
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedRelays.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
|
||||||
|
|
||||||
|
// Decrypt NWC connections
|
||||||
|
debug('handleUnlockRequest: Decrypting NWC connections...');
|
||||||
|
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
|
||||||
|
for (const nwc of browserSyncData.nwcConnections ?? []) {
|
||||||
|
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedNwcConnections.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
|
||||||
|
|
||||||
|
// Decrypt Cashu mints
|
||||||
|
debug('handleUnlockRequest: Decrypting Cashu mints...');
|
||||||
|
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
|
||||||
|
for (const mint of browserSyncData.cashuMints ?? []) {
|
||||||
|
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
|
||||||
|
decryptedCashuMints.push(decrypted);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
|
||||||
|
|
||||||
|
// Decrypt selectedIdentityId
|
||||||
|
let decryptedSelectedIdentityId: string | null = null;
|
||||||
|
if (browserSyncData.selectedIdentityId !== null) {
|
||||||
|
decryptedSelectedIdentityId = await decryptValue(
|
||||||
|
browserSyncData.selectedIdentityId,
|
||||||
|
browserSyncData.iv,
|
||||||
|
keyOrPassword,
|
||||||
|
isV2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
|
||||||
|
|
||||||
|
// Build session data
|
||||||
|
const browserSessionData: BrowserSessionData = {
|
||||||
|
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||||
|
vaultKey: isV2 ? vaultKey : undefined,
|
||||||
|
iv: browserSyncData.iv,
|
||||||
|
salt: browserSyncData.salt,
|
||||||
|
permissions: decryptedPermissions,
|
||||||
|
identities: decryptedIdentities,
|
||||||
|
selectedIdentityId: decryptedSelectedIdentityId,
|
||||||
|
relays: decryptedRelays,
|
||||||
|
nwcConnections: decryptedNwcConnections,
|
||||||
|
cashuMints: decryptedCashuMints,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save session data
|
||||||
|
debug('handleUnlockRequest: Saving session data...');
|
||||||
|
await browser.storage.session.set(browserSessionData as unknown as Record<string, unknown>);
|
||||||
|
debug('handleUnlockRequest: Unlock complete!');
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
debug(`handleUnlockRequest: Error: ${error.message}`);
|
||||||
|
return { success: false, error: error.message || 'Unlock failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the unlock popup window
|
||||||
|
*/
|
||||||
|
export async function openUnlockPopup(host?: string): Promise<void> {
|
||||||
|
const width = 375;
|
||||||
|
const height = 500;
|
||||||
|
const { top, left } = await getPosition(width, height);
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
let url = `unlock.html?id=${id}`;
|
||||||
|
if (host) {
|
||||||
|
url += `&host=${encodeURIComponent(host)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.windows.create({
|
||||||
|
type: 'popup',
|
||||||
|
url,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ import {
|
|||||||
debug,
|
debug,
|
||||||
getBrowserSessionData,
|
getBrowserSessionData,
|
||||||
getPosition,
|
getPosition,
|
||||||
|
handleUnlockRequest,
|
||||||
nip04Decrypt,
|
nip04Decrypt,
|
||||||
nip04Encrypt,
|
nip04Encrypt,
|
||||||
nip44Decrypt,
|
nip44Decrypt,
|
||||||
nip44Encrypt,
|
nip44Encrypt,
|
||||||
|
openUnlockPopup,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
PromptResponseMessage,
|
PromptResponseMessage,
|
||||||
shouldRecklessModeApprove,
|
shouldRecklessModeApprove,
|
||||||
signEvent,
|
signEvent,
|
||||||
storePermission,
|
storePermission,
|
||||||
|
UnlockRequestMessage,
|
||||||
|
UnlockResponseMessage,
|
||||||
} from './background-common';
|
} from './background-common';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
@@ -33,8 +37,49 @@ const openPrompts = new Map<
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
// Track if unlock popup is already open
|
||||||
|
let unlockPopupOpen = false;
|
||||||
|
|
||||||
|
// Queue of pending NIP-07 requests waiting for unlock
|
||||||
|
const pendingRequests: {
|
||||||
|
request: BackgroundRequestMessage;
|
||||||
|
resolve: (result: any) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||||
debug('Message received');
|
debug('Message received');
|
||||||
|
|
||||||
|
// Handle unlock request from unlock popup
|
||||||
|
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
|
||||||
|
const unlockReq = message as UnlockRequestMessage;
|
||||||
|
debug('Processing unlock request');
|
||||||
|
const result = await handleUnlockRequest(unlockReq.password);
|
||||||
|
const response: UnlockResponseMessage = {
|
||||||
|
type: 'unlock-response',
|
||||||
|
id: unlockReq.id,
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
unlockPopupOpen = false;
|
||||||
|
// Process any pending NIP-07 requests
|
||||||
|
debug(`Processing ${pendingRequests.length} pending requests`);
|
||||||
|
while (pendingRequests.length > 0) {
|
||||||
|
const pending = pendingRequests.shift()!;
|
||||||
|
try {
|
||||||
|
const pendingResult = await processNip07Request(pending.request);
|
||||||
|
pending.resolve(pendingResult);
|
||||||
|
} catch (error) {
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
||||||
debug(request);
|
debug(request);
|
||||||
|
|
||||||
@@ -55,6 +100,32 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
|
|
||||||
const browserSessionData = await getBrowserSessionData();
|
const browserSessionData = await getBrowserSessionData();
|
||||||
|
|
||||||
|
if (!browserSessionData) {
|
||||||
|
// Vault is locked - open unlock popup and queue the request
|
||||||
|
const req = request as BackgroundRequestMessage;
|
||||||
|
debug('Vault locked, opening unlock popup');
|
||||||
|
|
||||||
|
if (!unlockPopupOpen) {
|
||||||
|
unlockPopupOpen = true;
|
||||||
|
await openUnlockPopup(req.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue this request to be processed after unlock
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push({ request: req, resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the NIP-07 request
|
||||||
|
return processNip07Request(request as BackgroundRequestMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a NIP-07 request after vault is unlocked
|
||||||
|
*/
|
||||||
|
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
|
||||||
|
const browserSessionData = await getBrowserSessionData();
|
||||||
|
|
||||||
if (!browserSessionData) {
|
if (!browserSessionData) {
|
||||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||||
}
|
}
|
||||||
@@ -67,8 +138,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
throw new Error('No Nostr identity available at endpoint.');
|
throw new Error('No Nostr identity available at endpoint.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const req = request as BackgroundRequestMessage;
|
|
||||||
|
|
||||||
// Check reckless mode first
|
// Check reckless mode first
|
||||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||||
debug(`recklessApprove result: ${recklessApprove}`);
|
debug(`recklessApprove result: ${recklessApprove}`);
|
||||||
@@ -212,4 +281,4 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
default:
|
default:
|
||||||
throw new Error(`Not supported request method '${req.method}'.`);
|
throw new Error(`Not supported request method '${req.method}'.`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|||||||
106
projects/firefox/src/unlock.ts
Normal file
106
projects/firefox/src/unlock.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
export interface UnlockRequestMessage {
|
||||||
|
type: 'unlock-request';
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockResponseMessage {
|
||||||
|
type: 'unlock-response';
|
||||||
|
id: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const id = params.get('id') as string;
|
||||||
|
const host = params.get('host');
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
|
||||||
|
const togglePasswordBtn = document.getElementById('togglePassword');
|
||||||
|
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
|
||||||
|
const derivingOverlay = document.getElementById('derivingOverlay');
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
const hostInfo = document.getElementById('hostInfo');
|
||||||
|
const hostSpan = document.getElementById('hostSpan');
|
||||||
|
|
||||||
|
// Show host info if available
|
||||||
|
if (host && hostInfo && hostSpan) {
|
||||||
|
hostSpan.innerText = host;
|
||||||
|
hostInfo.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle password visibility
|
||||||
|
togglePasswordBtn?.addEventListener('click', () => {
|
||||||
|
if (passwordInput.type === 'password') {
|
||||||
|
passwordInput.type = 'text';
|
||||||
|
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
|
||||||
|
} else {
|
||||||
|
passwordInput.type = 'password';
|
||||||
|
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable/disable unlock button based on password input
|
||||||
|
passwordInput?.addEventListener('input', () => {
|
||||||
|
unlockBtn.disabled = !passwordInput.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle enter key
|
||||||
|
passwordInput?.addEventListener('keyup', (e) => {
|
||||||
|
if (e.key === 'Enter' && passwordInput.value) {
|
||||||
|
attemptUnlock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unlock button click
|
||||||
|
unlockBtn?.addEventListener('click', attemptUnlock);
|
||||||
|
|
||||||
|
async function attemptUnlock() {
|
||||||
|
if (!passwordInput?.value) return;
|
||||||
|
|
||||||
|
// Show deriving overlay
|
||||||
|
derivingOverlay?.classList.remove('hidden');
|
||||||
|
errorAlert?.classList.add('hidden');
|
||||||
|
|
||||||
|
const message: UnlockRequestMessage = {
|
||||||
|
type: 'unlock-request',
|
||||||
|
id,
|
||||||
|
password: passwordInput.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Success - close the window
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
// Failed - show error
|
||||||
|
derivingOverlay?.classList.add('hidden');
|
||||||
|
showError(response.error || 'Invalid password');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send unlock message:', error);
|
||||||
|
derivingOverlay?.classList.add('hidden');
|
||||||
|
showError('Failed to unlock vault');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message: string) {
|
||||||
|
if (errorAlert && errorMessage) {
|
||||||
|
errorMessage.innerText = message;
|
||||||
|
errorAlert.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
errorAlert.classList.add('hidden');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus password input on load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
passwordInput?.focus();
|
||||||
|
});
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"src/plebian-signer-extension.ts",
|
"src/plebian-signer-extension.ts",
|
||||||
"src/plebian-signer-content-script.ts",
|
"src/plebian-signer-content-script.ts",
|
||||||
"src/prompt.ts",
|
"src/prompt.ts",
|
||||||
"src/options.ts"
|
"src/options.ts",
|
||||||
|
"src/unlock.ts"
|
||||||
],
|
],
|
||||||
"include": ["src/**/*.d.ts"]
|
"include": ["src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user