diff --git a/chrome_prepare_manifest.sh b/chrome_prepare_manifest.sh index 7638c4c..e752a02 100755 --- a/chrome_prepare_manifest.sh +++ b/chrome_prepare_manifest.sh @@ -1,6 +1,7 @@ #!/bin/bash -version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"') +# Extract version and strip 'v' prefix if present (manifest requires bare semver) +version=$( cat package.json | jq -r '.custom.chrome.version' | sed 's/^v//') jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json diff --git a/firefox_prepare_manifest.sh b/firefox_prepare_manifest.sh index 8d25799..ba190f1 100755 --- a/firefox_prepare_manifest.sh +++ b/firefox_prepare_manifest.sh @@ -1,6 +1,7 @@ #!/bin/bash -version=$( cat package.json | jq '.custom.firefox.version' | tr -d '"') +# Extract version and strip 'v' prefix if present (manifest requires bare semver) +version=$( cat package.json | jq -r '.custom.firefox.version' | sed 's/^v//') jq '.version = $newVersion' --arg newVersion $version ./projects/firefox/public/manifest.json > ./projects/firefox/public/tmp.manifest.json && mv ./projects/firefox/public/tmp.manifest.json ./projects/firefox/public/manifest.json diff --git a/package.json b/package.json index 9df26de..e519654 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "plebeian-signer", - "version": "v0.0.8", + "version": "v0.0.9", "custom": { "chrome": { - "version": "v0.0.8" + "version": "v0.0.9" }, "firefox": { - "version": "v0.0.8" + "version": "v0.0.9" } }, "scripts": { diff --git a/projects/chrome/public/manifest.json b/projects/chrome/public/manifest.json index 90c91e4..cb05eae 100644 --- a/projects/chrome/public/manifest.json +++ b/projects/chrome/public/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Plebeian Signer - Nostr Identity Manager & Signer", "description": "Manage and switch between multiple identities while interacting with Nostr apps", - "version": "v0.0.8", + "version": "0.0.9", "homepage_url": "https://git.mleku.dev/mleku/plebeian-signer", "options_page": "options.html", "permissions": [ diff --git a/projects/chrome/public/person-fill.svg b/projects/chrome/public/person-fill.svg index 660a118..443f3c7 100644 --- a/projects/chrome/public/person-fill.svg +++ b/projects/chrome/public/person-fill.svg @@ -1,3 +1,3 @@ - - - \ No newline at end of file + + + diff --git a/projects/chrome/src/app/app.routes.ts b/projects/chrome/src/app/app.routes.ts index 7256970..475e8b4 100644 --- a/projects/chrome/src/app/app.routes.ts +++ b/projects/chrome/src/app/app.routes.ts @@ -16,6 +16,7 @@ import { KeysComponent as EditIdentityKeysComponent } from './components/edit-id import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component'; import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component'; import { VaultImportComponent } from './components/vault-import/vault-import.component'; +import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component'; export const routes: Routes = [ { @@ -70,6 +71,10 @@ export const routes: Routes = [ path: 'new-identity', component: NewIdentityComponent, }, + { + path: 'whitelisted-apps', + component: WhitelistedAppsComponent, + }, { path: 'edit-identity/:id', component: EditIdentityComponent, diff --git a/projects/chrome/src/app/components/home/identities/identities.component.html b/projects/chrome/src/app/components/home/identities/identities.component.html index aae8b09..af3c483 100644 --- a/projects/chrome/src/app/components/home/identities/identities.component.html +++ b/projects/chrome/src/app/components/home/identities/identities.component.html @@ -11,6 +11,30 @@ +
+ + +
+ @let sessionData = storage.getBrowserSessionHandler().browserSessionData; @let identities = sessionData?.identities ?? []; @@ -34,7 +58,7 @@ class="avatar" [src]="getAvatarUrl(identity)" alt="" - (error)="$any($event.target).src = 'assets/person-fill.svg'" + (error)="$any($event.target).src = 'person-fill.svg'" /> {{ getDisplayName(identity) }} profile for quick lookup #profileCache = new Map(); + get isRecklessMode(): boolean { + return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false; + } + async ngOnInit() { await this.#profileMetadata.initialize(); this.#loadProfiles(); @@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit { getAvatarUrl(identity: Identity_DECRYPTED): string { const profile = this.#profileCache.get(identity.id); - return profile?.picture || 'assets/person-fill.svg'; + return profile?.picture || 'person-fill.svg'; } getDisplayName(identity: Identity_DECRYPTED): string { @@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit { async onClickSelectIdentity(identityId: string) { await this.storage.switchIdentity(identityId); } + + async onToggleRecklessMode() { + const newValue = !this.isRecklessMode; + await this.storage.getSignerMetaHandler().setRecklessMode(newValue); + } + + onClickWhitelistedApps() { + this.#router.navigateByUrl('/whitelisted-apps'); + } } diff --git a/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.html b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.html new file mode 100644 index 0000000..1063baa --- /dev/null +++ b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.html @@ -0,0 +1,45 @@ +
+ + Whitelisted Apps +
+ +
+ + +
+ @if (whitelistedHosts.length === 0) { +
+ No whitelisted apps yet +
+ @if (isRecklessMode) { +
+ ⚠ All sites will be auto-approved without prompting +
+ } + } + + @for (host of whitelistedHosts; track host) { +
+ {{ host }} + +
+ } +
+
+ + + diff --git a/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.scss b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.scss new file mode 100644 index 0000000..73142fc --- /dev/null +++ b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.scss @@ -0,0 +1,134 @@ +:host { + height: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; + + .custom-header { + padding: var(--size); + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto; + align-items: center; + background: var(--background); + position: sticky; + top: 0; + z-index: 10; + + .back-btn { + grid-column-start: 1; + grid-column-end: 2; + grid-row-start: 1; + grid-row-end: 2; + justify-self: start; + background: transparent; + border: none; + color: var(--foreground); + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + z-index: 1; + + &:hover { + background: var(--background-light); + } + + i { + font-size: 20px; + } + } + + .text { + grid-column-start: 1; + grid-column-end: 2; + grid-row-start: 1; + grid-row-end: 2; + font-family: var(--font-heading); + font-size: 20px; + font-weight: 700; + letter-spacing: 0.05rem; + justify-self: center; + } + } + + .content { + padding: 0 var(--size) var(--size) var(--size); + flex-grow: 1; + display: flex; + flex-direction: column; + + .whitelist-btn { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: var(--size); + } + + .hosts-list { + flex-grow: 1; + + .empty-state { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + text-align: center; + } + + .warning-note { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + background: rgba(255, 193, 7, 0.15); + border: 1px solid rgba(255, 193, 7, 0.4); + border-radius: 8px; + text-align: center; + + span { + font-size: 13px; + color: #ffc107; + } + } + + .host-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--background-light); + border-radius: 8px; + margin-bottom: 8px; + + .host-name { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .remove-btn { + background: transparent; + border: none; + color: var(--muted-foreground); + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease, background-color 0.15s ease; + + &:hover { + color: var(--destructive); + background: var(--background-light-hover); + } + + i { + font-size: 14px; + } + } + } + } + } +} diff --git a/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.ts b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.ts new file mode 100644 index 0000000..61c994f --- /dev/null +++ b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.ts @@ -0,0 +1,72 @@ +import { Component, inject, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { + ConfirmComponent, + NavComponent, + StorageService, + ToastComponent, +} from '@common'; + +@Component({ + selector: 'app-whitelisted-apps', + templateUrl: './whitelisted-apps.component.html', + styleUrl: './whitelisted-apps.component.scss', + imports: [ToastComponent, ConfirmComponent], +}) +export class WhitelistedAppsComponent extends NavComponent { + @ViewChild('toast') toast!: ToastComponent; + @ViewChild('confirm') confirm!: ConfirmComponent; + + readonly storage = inject(StorageService); + readonly #router = inject(Router); + + get whitelistedHosts(): string[] { + return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? []; + } + + get isRecklessMode(): boolean { + return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false; + } + + async onClickWhitelistCurrentTab() { + try { + // Get current active tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs.length === 0 || !tabs[0].url) { + this.toast.show('No active tab found'); + return; + } + + const url = new URL(tabs[0].url); + const host = url.host; + + if (!host) { + this.toast.show('Cannot get host from current tab'); + return; + } + + // Check if already whitelisted + if (this.whitelistedHosts.includes(host)) { + this.toast.show(`${host} is already whitelisted`); + return; + } + + await this.storage.getSignerMetaHandler().addWhitelistedHost(host); + this.toast.show(`Added ${host} to whitelist`); + } catch (error) { + console.error('Error getting current tab:', error); + this.toast.show('Error getting current tab'); + } + } + + onClickRemoveHost(host: string) { + this.confirm.show(`Remove ${host} from whitelist?`, async () => { + await this.storage.getSignerMetaHandler().removeWhitelistedHost(host); + this.toast.show(`Removed ${host} from whitelist`); + }); + } + + onClickBack() { + this.#router.navigateByUrl('/home/identities'); + } +} diff --git a/projects/chrome/src/background-common.ts b/projects/chrome/src/background-common.ts index 6eeccc3..ffa6cbe 100644 --- a/projects/chrome/src/background-common.ts +++ b/projects/chrome/src/background-common.ts @@ -48,6 +48,40 @@ export const getBrowserSessionData = async function (): Promise< return browserSessionData as BrowserSessionData; }; +export const getSignerMetaData = async function (): Promise { + const signerMetaHandler = new ChromeMetaHandler(); + return (await signerMetaHandler.loadFullData()) as SignerMetaData; +}; + +/** + * Check if reckless mode should auto-approve the request. + * Returns true if should auto-approve, false if should use normal permission flow. + * + * Logic: + * - If reckless mode is OFF → return false (use normal flow) + * - If reckless mode is ON and whitelist is empty → return true (approve all) + * - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist + */ +export const shouldRecklessModeApprove = async function ( + host: string +): Promise { + const signerMetaData = await getSignerMetaData(); + + if (!signerMetaData.recklessMode) { + return false; + } + + const whitelistedHosts = signerMetaData.whitelistedHosts ?? []; + + if (whitelistedHosts.length === 0) { + // Reckless mode ON, no whitelist → approve all + return true; + } + + // Reckless mode ON, whitelist has entries → only approve if host is whitelisted + return whitelistedHosts.includes(host); +}; + export const getBrowserSyncData = async function (): Promise< BrowserSyncData | undefined > { diff --git a/projects/chrome/src/background.ts b/projects/chrome/src/background.ts index 01fa1cb..5577f1b 100644 --- a/projects/chrome/src/background.ts +++ b/projects/chrome/src/background.ts @@ -12,6 +12,7 @@ import { nip44Encrypt, PromptResponse, PromptResponseMessage, + shouldRecklessModeApprove, signEvent, storePermission, } from './background-common'; @@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => { } const req = request as BackgroundRequestMessage; - const permissionState = checkPermissions( - browserSessionData, - currentIdentity, - req.host, - req.method, - req.params - ); - if (permissionState === false) { - throw new Error('Permission denied'); - } + // Check reckless mode first + const recklessApprove = await shouldRecklessModeApprove(req.host); + if (recklessApprove) { + debug('Request auto-approved via reckless mode.'); + } else { + // Normal permission flow + const permissionState = checkPermissions( + browserSessionData, + currentIdentity, + req.host, + req.method, + req.params + ); - if (permissionState === undefined) { - // Ask user for permission. - const width = 375; - const height = 600; - const { top, left } = await getPosition(width, height); - - const base64Event = Buffer.from( - JSON.stringify(req.params ?? {}, undefined, 2) - ).toString('base64'); - - const response = await new Promise((resolve, reject) => { - const id = crypto.randomUUID(); - openPrompts.set(id, { resolve, reject }); - browser.windows.create({ - type: 'popup', - url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`, - height, - width, - top, - left, - }); - }); - debug(response); - if (response === 'approve' || response === 'reject') { - await storePermission( - browserSessionData, - currentIdentity, - req.host, - req.method, - response === 'approve' ? 'allow' : 'deny', - req.params?.kind - ); - } - - if (['reject', 'reject-once'].includes(response)) { + if (permissionState === false) { throw new Error('Permission denied'); } - } else { - debug('Request allowed (via saved permission).'); + + if (permissionState === undefined) { + // Ask user for permission. + const width = 375; + const height = 600; + const { top, left } = await getPosition(width, height); + + const base64Event = Buffer.from( + JSON.stringify(req.params ?? {}, undefined, 2) + ).toString('base64'); + + const response = await new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + openPrompts.set(id, { resolve, reject }); + browser.windows.create({ + type: 'popup', + url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`, + height, + width, + top, + left, + }); + }); + debug(response); + if (response === 'approve' || response === 'reject') { + await storePermission( + browserSessionData, + currentIdentity, + req.host, + req.method, + response === 'approve' ? 'allow' : 'deny', + req.params?.kind + ); + } + + if (['reject', 'reject-once'].includes(response)) { + throw new Error('Permission denied'); + } + } else { + debug('Request allowed (via saved permission).'); + } } const relays: Relays = {}; diff --git a/projects/common/src/lib/services/storage/signer-meta-handler.ts b/projects/common/src/lib/services/storage/signer-meta-handler.ts index f4b919c..bb8123e 100644 --- a/projects/common/src/lib/services/storage/signer-meta-handler.ts +++ b/projects/common/src/lib/services/storage/signer-meta-handler.ts @@ -8,7 +8,7 @@ export abstract class SignerMetaHandler { #signerMetaData?: SignerMetaData; - readonly metaProperties = ['syncFlow', 'vaultSnapshots']; + readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts']; /** * Load the full data from the storage. If the storage is used for storing * other data (e.g. browser sync data when the user decided to NOT sync), @@ -40,4 +40,53 @@ export abstract class SignerMetaHandler { } abstract clearData(keep: string[]): Promise; + + /** + * Sets the reckless mode and immediately saves it. + */ + async setRecklessMode(enabled: boolean): Promise { + if (!this.#signerMetaData) { + this.#signerMetaData = { + recklessMode: enabled, + }; + } else { + this.#signerMetaData.recklessMode = enabled; + } + + await this.saveFullData(this.#signerMetaData); + } + + /** + * Adds a host to the whitelist and immediately saves it. + */ + async addWhitelistedHost(host: string): Promise { + if (!this.#signerMetaData) { + this.#signerMetaData = { + whitelistedHosts: [host], + }; + } else { + const hosts = this.#signerMetaData.whitelistedHosts ?? []; + if (!hosts.includes(host)) { + hosts.push(host); + this.#signerMetaData.whitelistedHosts = hosts; + } + } + + await this.saveFullData(this.#signerMetaData); + } + + /** + * Removes a host from the whitelist and immediately saves it. + */ + async removeWhitelistedHost(host: string): Promise { + if (!this.#signerMetaData?.whitelistedHosts) { + return; + } + + this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter( + (h) => h !== host + ); + + await this.saveFullData(this.#signerMetaData); + } } diff --git a/projects/common/src/lib/services/storage/types.ts b/projects/common/src/lib/services/storage/types.ts index 4be0de2..971395c 100644 --- a/projects/common/src/lib/services/storage/types.ts +++ b/projects/common/src/lib/services/storage/types.ts @@ -92,6 +92,12 @@ export interface SignerMetaData { syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync)) vaultSnapshots?: SignerMetaData_VaultSnapshot[]; + + // Reckless mode: auto-approve all actions without prompting + recklessMode?: boolean; + + // Whitelisted hosts: auto-approve all actions from these hosts + whitelistedHosts?: string[]; } /** diff --git a/projects/firefox/public/manifest.json b/projects/firefox/public/manifest.json index 7463aa2..d630a14 100644 --- a/projects/firefox/public/manifest.json +++ b/projects/firefox/public/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Plebeian Signer", "description": "Nostr Identity Manager & Signer", - "version": "v0.0.8", + "version": "0.0.9", "homepage_url": "https://git.mleku.dev/mleku/plebeian-signer", "options_page": "options.html", "permissions": [ diff --git a/projects/firefox/public/person-fill.svg b/projects/firefox/public/person-fill.svg index 660a118..443f3c7 100644 --- a/projects/firefox/public/person-fill.svg +++ b/projects/firefox/public/person-fill.svg @@ -1,3 +1,3 @@ - - - \ No newline at end of file + + + diff --git a/projects/firefox/src/app/app.routes.ts b/projects/firefox/src/app/app.routes.ts index fd7cd58..c3f5777 100644 --- a/projects/firefox/src/app/app.routes.ts +++ b/projects/firefox/src/app/app.routes.ts @@ -16,6 +16,7 @@ import { WelcomeComponent } from './components/welcome/welcome.component'; import { VaultLoginComponent } from './components/vault-login/vault-login.component'; import { VaultCreateComponent } from './components/vault-create/vault-create.component'; import { VaultImportComponent } from './components/vault-import/vault-import.component'; +import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component'; export const routes: Routes = [ { @@ -70,6 +71,10 @@ export const routes: Routes = [ path: 'new-identity', component: NewIdentityComponent, }, + { + path: 'whitelisted-apps', + component: WhitelistedAppsComponent, + }, { path: 'edit-identity/:id', component: EditIdentityComponent, diff --git a/projects/firefox/src/app/components/home/identities/identities.component.html b/projects/firefox/src/app/components/home/identities/identities.component.html index aae8b09..af3c483 100644 --- a/projects/firefox/src/app/components/home/identities/identities.component.html +++ b/projects/firefox/src/app/components/home/identities/identities.component.html @@ -11,6 +11,30 @@ +
+ + +
+ @let sessionData = storage.getBrowserSessionHandler().browserSessionData; @let identities = sessionData?.identities ?? []; @@ -34,7 +58,7 @@ class="avatar" [src]="getAvatarUrl(identity)" alt="" - (error)="$any($event.target).src = 'assets/person-fill.svg'" + (error)="$any($event.target).src = 'person-fill.svg'" /> {{ getDisplayName(identity) }} profile for quick lookup #profileCache = new Map(); + get isRecklessMode(): boolean { + return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false; + } + async ngOnInit() { await this.#profileMetadata.initialize(); this.#loadProfiles(); @@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit { getAvatarUrl(identity: Identity_DECRYPTED): string { const profile = this.#profileCache.get(identity.id); - return profile?.picture || 'assets/person-fill.svg'; + return profile?.picture || 'person-fill.svg'; } getDisplayName(identity: Identity_DECRYPTED): string { @@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit { async onClickSelectIdentity(identityId: string) { await this.storage.switchIdentity(identityId); } + + async onToggleRecklessMode() { + const newValue = !this.isRecklessMode; + await this.storage.getSignerMetaHandler().setRecklessMode(newValue); + } + + onClickWhitelistedApps() { + this.#router.navigateByUrl('/whitelisted-apps'); + } } diff --git a/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.html b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.html new file mode 100644 index 0000000..1063baa --- /dev/null +++ b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.html @@ -0,0 +1,45 @@ +
+ + Whitelisted Apps +
+ +
+ + +
+ @if (whitelistedHosts.length === 0) { +
+ No whitelisted apps yet +
+ @if (isRecklessMode) { +
+ ⚠ All sites will be auto-approved without prompting +
+ } + } + + @for (host of whitelistedHosts; track host) { +
+ {{ host }} + +
+ } +
+
+ + + diff --git a/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.scss b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.scss new file mode 100644 index 0000000..73142fc --- /dev/null +++ b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.scss @@ -0,0 +1,134 @@ +:host { + height: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; + + .custom-header { + padding: var(--size); + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto; + align-items: center; + background: var(--background); + position: sticky; + top: 0; + z-index: 10; + + .back-btn { + grid-column-start: 1; + grid-column-end: 2; + grid-row-start: 1; + grid-row-end: 2; + justify-self: start; + background: transparent; + border: none; + color: var(--foreground); + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + z-index: 1; + + &:hover { + background: var(--background-light); + } + + i { + font-size: 20px; + } + } + + .text { + grid-column-start: 1; + grid-column-end: 2; + grid-row-start: 1; + grid-row-end: 2; + font-family: var(--font-heading); + font-size: 20px; + font-weight: 700; + letter-spacing: 0.05rem; + justify-self: center; + } + } + + .content { + padding: 0 var(--size) var(--size) var(--size); + flex-grow: 1; + display: flex; + flex-direction: column; + + .whitelist-btn { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: var(--size); + } + + .hosts-list { + flex-grow: 1; + + .empty-state { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + text-align: center; + } + + .warning-note { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + background: rgba(255, 193, 7, 0.15); + border: 1px solid rgba(255, 193, 7, 0.4); + border-radius: 8px; + text-align: center; + + span { + font-size: 13px; + color: #ffc107; + } + } + + .host-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--background-light); + border-radius: 8px; + margin-bottom: 8px; + + .host-name { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .remove-btn { + background: transparent; + border: none; + color: var(--muted-foreground); + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease, background-color 0.15s ease; + + &:hover { + color: var(--destructive); + background: var(--background-light-hover); + } + + i { + font-size: 14px; + } + } + } + } + } +} diff --git a/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.ts b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.ts new file mode 100644 index 0000000..dcc178d --- /dev/null +++ b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.ts @@ -0,0 +1,73 @@ +import { Component, inject, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { + ConfirmComponent, + NavComponent, + StorageService, + ToastComponent, +} from '@common'; +import browser from 'webextension-polyfill'; + +@Component({ + selector: 'app-whitelisted-apps', + templateUrl: './whitelisted-apps.component.html', + styleUrl: './whitelisted-apps.component.scss', + imports: [ToastComponent, ConfirmComponent], +}) +export class WhitelistedAppsComponent extends NavComponent { + @ViewChild('toast') toast!: ToastComponent; + @ViewChild('confirm') confirm!: ConfirmComponent; + + readonly storage = inject(StorageService); + readonly #router = inject(Router); + + get whitelistedHosts(): string[] { + return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? []; + } + + get isRecklessMode(): boolean { + return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false; + } + + async onClickWhitelistCurrentTab() { + try { + // Get current active tab + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (tabs.length === 0 || !tabs[0].url) { + this.toast.show('No active tab found'); + return; + } + + const url = new URL(tabs[0].url); + const host = url.host; + + if (!host) { + this.toast.show('Cannot get host from current tab'); + return; + } + + // Check if already whitelisted + if (this.whitelistedHosts.includes(host)) { + this.toast.show(`${host} is already whitelisted`); + return; + } + + await this.storage.getSignerMetaHandler().addWhitelistedHost(host); + this.toast.show(`Added ${host} to whitelist`); + } catch (error) { + console.error('Error getting current tab:', error); + this.toast.show('Error getting current tab'); + } + } + + onClickRemoveHost(host: string) { + this.confirm.show(`Remove ${host} from whitelist?`, async () => { + await this.storage.getSignerMetaHandler().removeWhitelistedHost(host); + this.toast.show(`Removed ${host} from whitelist`); + }); + } + + onClickBack() { + this.#router.navigateByUrl('/home/identities'); + } +} diff --git a/projects/firefox/src/background-common.ts b/projects/firefox/src/background-common.ts index 14c9762..213c801 100644 --- a/projects/firefox/src/background-common.ts +++ b/projects/firefox/src/background-common.ts @@ -49,6 +49,40 @@ export const getBrowserSessionData = async function (): Promise< return browserSessionData as unknown as BrowserSessionData; }; +export const getSignerMetaData = async function (): Promise { + const signerMetaHandler = new FirefoxMetaHandler(); + return (await signerMetaHandler.loadFullData()) as SignerMetaData; +}; + +/** + * Check if reckless mode should auto-approve the request. + * Returns true if should auto-approve, false if should use normal permission flow. + * + * Logic: + * - If reckless mode is OFF → return false (use normal flow) + * - If reckless mode is ON and whitelist is empty → return true (approve all) + * - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist + */ +export const shouldRecklessModeApprove = async function ( + host: string +): Promise { + const signerMetaData = await getSignerMetaData(); + + if (!signerMetaData.recklessMode) { + return false; + } + + const whitelistedHosts = signerMetaData.whitelistedHosts ?? []; + + if (whitelistedHosts.length === 0) { + // Reckless mode ON, no whitelist → approve all + return true; + } + + // Reckless mode ON, whitelist has entries → only approve if host is whitelisted + return whitelistedHosts.includes(host); +}; + export const getBrowserSyncData = async function (): Promise< BrowserSyncData | undefined > { diff --git a/projects/firefox/src/background.ts b/projects/firefox/src/background.ts index 01fa1cb..5577f1b 100644 --- a/projects/firefox/src/background.ts +++ b/projects/firefox/src/background.ts @@ -12,6 +12,7 @@ import { nip44Encrypt, PromptResponse, PromptResponseMessage, + shouldRecklessModeApprove, signEvent, storePermission, } from './background-common'; @@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => { } const req = request as BackgroundRequestMessage; - const permissionState = checkPermissions( - browserSessionData, - currentIdentity, - req.host, - req.method, - req.params - ); - if (permissionState === false) { - throw new Error('Permission denied'); - } + // Check reckless mode first + const recklessApprove = await shouldRecklessModeApprove(req.host); + if (recklessApprove) { + debug('Request auto-approved via reckless mode.'); + } else { + // Normal permission flow + const permissionState = checkPermissions( + browserSessionData, + currentIdentity, + req.host, + req.method, + req.params + ); - if (permissionState === undefined) { - // Ask user for permission. - const width = 375; - const height = 600; - const { top, left } = await getPosition(width, height); - - const base64Event = Buffer.from( - JSON.stringify(req.params ?? {}, undefined, 2) - ).toString('base64'); - - const response = await new Promise((resolve, reject) => { - const id = crypto.randomUUID(); - openPrompts.set(id, { resolve, reject }); - browser.windows.create({ - type: 'popup', - url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`, - height, - width, - top, - left, - }); - }); - debug(response); - if (response === 'approve' || response === 'reject') { - await storePermission( - browserSessionData, - currentIdentity, - req.host, - req.method, - response === 'approve' ? 'allow' : 'deny', - req.params?.kind - ); - } - - if (['reject', 'reject-once'].includes(response)) { + if (permissionState === false) { throw new Error('Permission denied'); } - } else { - debug('Request allowed (via saved permission).'); + + if (permissionState === undefined) { + // Ask user for permission. + const width = 375; + const height = 600; + const { top, left } = await getPosition(width, height); + + const base64Event = Buffer.from( + JSON.stringify(req.params ?? {}, undefined, 2) + ).toString('base64'); + + const response = await new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + openPrompts.set(id, { resolve, reject }); + browser.windows.create({ + type: 'popup', + url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`, + height, + width, + top, + left, + }); + }); + debug(response); + if (response === 'approve' || response === 'reject') { + await storePermission( + browserSessionData, + currentIdentity, + req.host, + req.method, + response === 'approve' ? 'allow' : 'deny', + req.params?.kind + ); + } + + if (['reject', 'reject-once'].includes(response)) { + throw new Error('Permission denied'); + } + } else { + debug('Request allowed (via saved permission).'); + } } const relays: Relays = {};