Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/app/components/Enable/NostrEnable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ConfirmOrCancel from "@components/ConfirmOrCancel";
import Container from "@components/Container";
import PublisherCard from "@components/PublisherCard";
import {
PopiconsNotificationAlertLine,
PopiconsCheckLine,
PopiconsGlassesSolid,
PopiconsHeartLine,
Expand Down Expand Up @@ -87,6 +88,11 @@ function NostrEnableComponent(props: Props) {
<PermissionPreset
title={t("presets.trust_fully.title")}
description={t("presets.trust_fully.description")}
grants={[
t("presets.trust_fully.grants.sign_anything"),
t("presets.trust_fully.grants.send_encrypted"),
]}
asks={[t("presets.always_asks.read_messages")]}
icon={<PopiconsHeartLine className="w-6 h-6" />}
onClick={() =>
setSelectedPreset(NostrPermissionPreset.TRUST_FULLY)
Expand All @@ -96,6 +102,11 @@ function NostrEnableComponent(props: Props) {
<PermissionPreset
title={t("presets.reasonable.title")}
description={t("presets.reasonable.description")}
grants={[t("presets.reasonable.grants.posts_and_zaps")]}
asks={[
t("presets.always_asks.identity"),
t("presets.always_asks.read_messages"),
]}
icon={<PopiconsLikeLine className="w-6 h-6" />}
onClick={() =>
setSelectedPreset(NostrPermissionPreset.REASONABLE)
Expand Down Expand Up @@ -135,6 +146,8 @@ function NostrEnableComponent(props: Props) {
type PermissionPresetProps = {
title: string;
description: string;
grants?: string[];
asks?: string[];
icon: React.ReactNode;
onClick: () => void;
isSelected: boolean;
Expand All @@ -143,6 +156,8 @@ function PermissionPreset({
icon,
title,
description,
grants,
asks,
onClick,
isSelected,
}: PermissionPresetProps) {
Expand Down Expand Up @@ -171,6 +186,28 @@ function PermissionPreset({
<div className="text-gray-600 dark:text-neutral-400 text-xs leading-4 md:text-sm">
{description}
</div>
{!!(grants?.length || asks?.length) && (
<ul className="pt-1 space-y-0.5 text-xs leading-4">
{grants?.map((grant) => (
<li
key={grant}
className="flex gap-1.5 text-gray-600 dark:text-neutral-400"
>
<PopiconsCheckLine className="w-3.5 flex-shrink-0" />
<span>{grant}</span>
</li>
))}
{asks?.map((ask) => (
<li
key={ask}
className="flex gap-1.5 text-gray-500 dark:text-neutral-500"
>
<PopiconsNotificationAlertLine className="w-3.5 flex-shrink-0" />
<span>{ask}</span>
</li>
))}
</ul>
)}
</div>
<div
className={classNames(
Expand Down
75 changes: 75 additions & 0 deletions src/common/utils/nostrPresets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { EventKind } from "~/extension/providers/nostr/types";
import { PermissionMethodNostr } from "~/types";

/**
* Event kinds the "reasonable" preset auto-approves.
*
* Limited to posting and social kinds. Kinds that change who the account is
* (profile, contacts, relay list), speak privately as the user, or authenticate
* the user to a relay or a website are deliberately absent: those always ask.
*/
export const REASONABLE_PRESET_EVENT_KINDS: EventKind[] = [
EventKind.Text,
EventKind.Repost,
EventKind.React,
EventKind.ZapRequest,
EventKind.MuteList,
EventKind.Bookmarks,
EventKind.LongNote,
EventKind.ProfileBadge,
EventKind.CreateBadge,
Comment thread
reneaaron marked this conversation as resolved.
EventKind.AppData,
];

/**
* Event kinds that are never auto-approved by a preset. Each one either changes
* the account's identity or authenticates as the user, so each is confirmed
* individually.
*/
export const ALWAYS_CONFIRMED_EVENT_KINDS: EventKind[] = [
EventKind.Metadata,
EventKind.Contacts,
EventKind.DM,
EventKind.RelayList,
EventKind.Authenticate,
EventKind.RemoteSign,
EventKind.HTTPAuth,
EventKind.UploadChunk,
];

/** Methods the "reasonable" preset auto-approves. */
export const REASONABLE_PRESET_METHODS: PermissionMethodNostr[] = [
PermissionMethodNostr.NOSTR_GETPUBLICKEY,
];

/**
* Reading the user's encrypted messages is never granted by a preset, not even
* by "I fully trust it" — every decryption is confirmed individually.
*/
export const NEVER_PRESET_GRANTED_METHODS: PermissionMethodNostr[] = [
PermissionMethodNostr.NOSTR_DECRYPT,
];

/** Methods the "I fully trust it" preset auto-approves. */
export const TRUST_FULLY_PRESET_METHODS: PermissionMethodNostr[] =
Object.values(PermissionMethodNostr).filter(
(method) => !NEVER_PRESET_GRANTED_METHODS.includes(method)
);

/**
* Permission methods no preset grants any more, revoked once on upgrade.
*
* The revoke is unconditional: a permission row records only its method, not
* which preset or prompt created it, so a grant the user made deliberately
* through "don't ask again" is removed along with the preset's. It is limited
* to methods no preset grants today, so nobody ends up narrower than the
* preset they picked. `nostr/encrypt` is deliberately absent - "I fully trust
* it" still grants it, and revoking it would prompt those users for a
* permission that preset still covers.
*/
export const WITHDRAWN_PRESET_PERMISSIONS: string[] = [
PermissionMethodNostr.NOSTR_DECRYPT,
...ALWAYS_CONFIRMED_EVENT_KINDS.map(
(kind) => `${PermissionMethodNostr.NOSTR_SIGNMESSAGE}/${kind}`
),
];
108 changes: 108 additions & 0 deletions src/extension/background-script/actions/nostr/__tests__/enable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import db from "~/extension/background-script/db";
import {
ALWAYS_CONFIRMED_EVENT_KINDS,
REASONABLE_PRESET_EVENT_KINDS,
} from "~/common/utils/nostrPresets";
import type { MessageAllowanceEnable, Sender } from "~/types";
import { NostrPermissionPreset, PermissionMethodNostr } from "~/types";

import enable from "../enable";

console.error = jest.fn();
console.info = jest.fn();

let preset = NostrPermissionPreset.REASONABLE;
const openPromptMock = jest.fn(async (_message?: unknown) => ({
data: { enabled: true, remember: true, preset },
}));
jest.mock("~/common/lib/utils", () => ({
openPrompt: (m: unknown) => openPromptMock(m),
}));

jest.mock("~/extension/background-script/state", () => ({
getState: () => ({
isUnlocked: jest.fn(async () => true),
getAccount: jest.fn(async () => ({ nostrPrivateKey: "11".repeat(32) })),
currentAccountId: "account-1",
settings: { browserNotifications: false },
}),
}));

const HOST = "example.com";
const sender = { origin: `https://${HOST}` } as Sender;

const connect = async () => {
await enable(
{
application: "LBE",
prompt: true,
action: "enable",
args: {},
origin: { host: HOST, name: "Example", icon: "" },
} as unknown as MessageAllowanceEnable,
sender
);
return (await db.permissions.toArray()).map((p) => p.method);
};

beforeEach(async () => {
await db.allowances.clear();
await db.permissions.clear();
openPromptMock.mockClear();
});

describe("nostr enable presets", () => {
test("the reasonable preset grants posting kinds only", async () => {
preset = NostrPermissionPreset.REASONABLE;
const granted = await connect();

for (const kind of REASONABLE_PRESET_EVENT_KINDS) {
expect(granted).toContain(
`${PermissionMethodNostr.NOSTR_SIGNMESSAGE}/${kind}`
);
}
expect(granted).toContain(PermissionMethodNostr.NOSTR_GETPUBLICKEY);
});

test("the reasonable preset grants neither decryption nor encryption", async () => {
preset = NostrPermissionPreset.REASONABLE;
const granted = await connect();

expect(granted).not.toContain(PermissionMethodNostr.NOSTR_DECRYPT);
expect(granted).not.toContain(PermissionMethodNostr.NOSTR_ENCRYPT);
});

test("the reasonable preset grants no identity or authentication kind", async () => {
preset = NostrPermissionPreset.REASONABLE;
const granted = await connect();

for (const kind of ALWAYS_CONFIRMED_EVENT_KINDS) {
expect(granted).not.toContain(
`${PermissionMethodNostr.NOSTR_SIGNMESSAGE}/${kind}`
);
}
});

test("no preset grants decryption, including trust fully", async () => {
preset = NostrPermissionPreset.TRUST_FULLY;
const granted = await connect();

expect(granted).not.toContain(PermissionMethodNostr.NOSTR_DECRYPT);
expect(granted).toContain(PermissionMethodNostr.NOSTR_ENCRYPT);
});

test("the paranoid preset grants nothing", async () => {
preset = NostrPermissionPreset.PARANOID;
const granted = await connect();

expect(granted).toEqual([]);
});

test("every granted permission is persisted before enable resolves", async () => {
preset = NostrPermissionPreset.REASONABLE;
// no waiting: the grants must be awaited, not left running after the return
const granted = await connect();

expect(granted).toHaveLength(REASONABLE_PRESET_EVENT_KINDS.length + 1);
});
});
61 changes: 18 additions & 43 deletions src/extension/background-script/actions/nostr/enable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
} from "~/types";

import { addPermissionFor } from "~/extension/background-script/permissions";
import { EventKind } from "~/extension/providers/nostr/types";
import {
REASONABLE_PRESET_EVENT_KINDS,
REASONABLE_PRESET_METHODS,
TRUST_FULLY_PRESET_METHODS,
} from "~/common/utils/nostrPresets";
import state from "../../state";
import { ExtensionIcon, setIcon } from "../setup/setIcon";

Expand Down Expand Up @@ -98,52 +102,23 @@ const enable = async (message: MessageAllowanceEnable, sender: Sender) => {
});
}
if (response.data.preset === NostrPermissionPreset.REASONABLE) {
// Add permissions
const permissions: PermissionMethodNostr[] = [
PermissionMethodNostr.NOSTR_GETPUBLICKEY,
PermissionMethodNostr.NOSTR_ENCRYPT,
PermissionMethodNostr.NOSTR_DECRYPT,
];
permissions.forEach(async (permission) => {
await addPermissionFor(permission, host, false);
});

// Add specific signing permissions

const reasonableEventKindIds = [
EventKind.Metadata,
EventKind.Text,
EventKind.Contacts,
EventKind.DM,
EventKind.Repost,
EventKind.React,
EventKind.ZapRequest,
EventKind.MuteList,
EventKind.RelayList,
EventKind.Bookmarks,
EventKind.Authenticate,
EventKind.HTTPAuth,
EventKind.LongNote,
EventKind.ProfileBadge,
EventKind.CreateBadge,
EventKind.AppData,
EventKind.UploadChunk,
EventKind.RemoteSign,
const permissions: string[] = [
...REASONABLE_PRESET_METHODS,
...REASONABLE_PRESET_EVENT_KINDS.map(
(kindId) => `${PermissionMethodNostr.NOSTR_SIGNMESSAGE}/${kindId}`
),
];
// when addding multiple permissions at once, the flow shall wait until all asynchronous addPermissionFor calls are completed.
await Promise.all(
reasonableEventKindIds.map((kindId) => {
addPermissionFor(
PermissionMethodNostr.NOSTR_SIGNMESSAGE + "/" + kindId,
host,
false
);
})
permissions.map((permission) =>
addPermissionFor(permission, host, false)
)
);
} else if (response.data.preset === NostrPermissionPreset.TRUST_FULLY) {
Object.values(PermissionMethodNostr).forEach(async (permission) => {
await addPermissionFor(permission, host, false);
});
await Promise.all(
TRUST_FULLY_PRESET_METHODS.map((permission) =>
addPermissionFor(permission, host, false)
)
);
}
await db.saveToStorage();
}
Expand Down
Loading
Loading