diff --git a/.gitignore b/.gitignore index dda7278..81d1395 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,4 @@ cython_debug/ #.idea/ .quartenv +.vscode/ diff --git a/nwc-frontend/src/hooks/useAppInfo.ts b/nwc-frontend/src/hooks/useAppInfo.ts index 7b28e29..e5a20f5 100644 --- a/nwc-frontend/src/hooks/useAppInfo.ts +++ b/nwc-frontend/src/hooks/useAppInfo.ts @@ -8,7 +8,8 @@ export const fetchAppInfo = async (clientId: string) => { return { clientId: appInfo.clientId, name: appInfo.name, - verified: appInfo.verified === "VERIFIED", + nip05Verified: appInfo.nip05Verification == "VERIFIED", + nip68Verification: appInfo.nip68Verification, domain: appInfo.domain, avatar: appInfo.avatar, }; diff --git a/nwc-frontend/src/permissions/PermissionsPage.tsx b/nwc-frontend/src/permissions/PermissionsPage.tsx index 6b2cd23..16a4f5a 100644 --- a/nwc-frontend/src/permissions/PermissionsPage.tsx +++ b/nwc-frontend/src/permissions/PermissionsPage.tsx @@ -198,7 +198,8 @@ export const PermissionsPage = () => { {appInfo && ( <> {appInfo.name} - {appInfo.verified && ( + {appInfo.nip05Verified && ( + // TODO: Add NIP-68 verification status diff --git a/nwc-frontend/src/permissions/PersonalizePage.tsx b/nwc-frontend/src/permissions/PersonalizePage.tsx index 5111bbf..3a3e970 100644 --- a/nwc-frontend/src/permissions/PersonalizePage.tsx +++ b/nwc-frontend/src/permissions/PersonalizePage.tsx @@ -116,7 +116,8 @@ export const PersonalizePage = ({ {appInfo && ( <> {appInfo.name} - {appInfo.verified && ( + {appInfo.nip05Verified && ( + // TODO: Add NIP-68 verification status diff --git a/nwc-frontend/src/types/AppInfo.ts b/nwc-frontend/src/types/AppInfo.ts index e0d4500..9cc97c2 100644 --- a/nwc-frontend/src/types/AppInfo.ts +++ b/nwc-frontend/src/types/AppInfo.ts @@ -1,7 +1,14 @@ export interface AppInfo { clientId: string; name: string; - verified: boolean; + nip05Verified: boolean; domain: string; avatar: string; + nip68Verification?: + | { + status: string; + authorityName: string; + authorityPubKey: string; + } + | null; } diff --git a/nwc_backend/api_handlers/client_app_lookup_handler.py b/nwc_backend/api_handlers/client_app_lookup_handler.py index fa07d05..76ba335 100644 --- a/nwc_backend/api_handlers/client_app_lookup_handler.py +++ b/nwc_backend/api_handlers/client_app_lookup_handler.py @@ -15,17 +15,26 @@ async def get_client_app() -> Response: client_app_info = await look_up_client_app_identity(client_id) if not client_app_info: return Response("Client app not found", status=404) + + nip68_verification_json = None + if client_app_info.app_authority_verification: + nip68_verification_json = { + "authorityName": client_app_info.app_authority_verification.authority_name, + "authorityPublicKey": client_app_info.app_authority_verification.authority_pubkey, + "status": client_app_info.app_authority_verification.status.value, + } return Response( json.dumps( { "clientId": client_id, "name": client_app_info.display_name, - "verified": ( + "nip05Verification": ( client_app_info.nip05.verification_status.value if client_app_info.nip05 else None ), + "nip68Verification": nip68_verification_json, "avatar": client_app_info.image_url, "domain": ( client_app_info.nip05.domain if client_app_info.nip05 else None diff --git a/nwc_backend/configs/local_dev.py b/nwc_backend/configs/local_dev.py index 18c6767..e69be4a 100644 --- a/nwc_backend/configs/local_dev.py +++ b/nwc_backend/configs/local_dev.py @@ -35,3 +35,8 @@ "execute_quote", "pay_to_address", ] + +# NIP-68 client app authorities which can verify app identity events. +CLIENT_APP_AUTHORITIES = [ + # "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol +] diff --git a/nwc_backend/configs/local_docker.py b/nwc_backend/configs/local_docker.py index e18be99..b2e69c5 100644 --- a/nwc_backend/configs/local_docker.py +++ b/nwc_backend/configs/local_docker.py @@ -34,3 +34,8 @@ "execute_quote", "pay_to_address", ] + +# NIP-68 client app authorities which can verify app identity events. +CLIENT_APP_AUTHORITIES = [ + # "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol +] diff --git a/nwc_backend/configs/testing.py b/nwc_backend/configs/testing.py index c18c23d..51e8f51 100644 --- a/nwc_backend/configs/testing.py +++ b/nwc_backend/configs/testing.py @@ -30,3 +30,7 @@ "execute_quote", "pay_to_address", ] + +CLIENT_APP_AUTHORITIES = [ + "nprofile1qqstg4syz8qyk9xeyp5j7haaw9nz67a6wzt80tmu5vn5g4ckpxlagvqpp4mhxue69uhkummn9ekx7mqnegwyj" # Fake authority at nos.lol +] diff --git a/nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py b/nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py new file mode 100644 index 0000000..72d7355 --- /dev/null +++ b/nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py @@ -0,0 +1,232 @@ +import json +from typing import List +from unittest.mock import AsyncMock, patch + +from nostr_sdk import ( + EventBuilder, + EventSource, + Filter, + Keys, + Kind, + KindEnum, + Metadata, + Tag, +) +from quart.app import QuartClient + +from nwc_backend.nostr.__tests__.fake_nostr_client import FakeNostrClient +from nwc_backend.nostr.client_app_identity_lookup import ( + Nip05, + Nip05VerificationStatus, + Nip68VerificationStatus, + look_up_client_app_identity, +) + +CLIENT_PUBKEY = "npub13msd7fakpaqerq036kk0c6pf9effz5nn5yk6nqj4gtwtzr5l6fxq64z8x5" +CLIENT_PRIVKEY = "nsec1e792rulwmsjanw783x39r8vcm23c2hwcandwahaw6wh39rfydshqxhfm7x" +CLIENT_ID = f"{CLIENT_PUBKEY} wss://nos.lol" + +AUTHORITY_PUBKEY = "npub1k3tqgywqfv2djgrf9a0m6utx94am5uykw7hhege8g3t3vzdl6scq6ewt9d" +AUTHORITY_PRIVKEY = "nsec1gn4guugvc8656dwqs3j486leffaepx2mvpym7qkh2k2vuuextvtsnv6x76" + + +async def test_unregistered(test_client: QuartClient) -> None: + fake_client = FakeNostrClient() + + async def on_get_events(filters: List[Filter], source: EventSource): + return [] + + fake_client.on_get_events = on_get_events + identity = await look_up_client_app_identity( + client_id=CLIENT_ID, + nostr_client_factory=lambda: fake_client, + ) + + assert identity is None + + +@patch.object(Nip05, "verify", new_callable=AsyncMock) +async def test_only_kind0( + mock_verify_nip05: AsyncMock, test_client: QuartClient +) -> None: + mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED + fake_client = FakeNostrClient() + + async def on_get_events(filters: List[Filter], source: EventSource): + return [ + EventBuilder.metadata( + Metadata() + .set_name("Blue Drink") + .set_nip05("_@bluedrink.com") + .set_picture("https://bluedrink.com/image.png") + ).to_event(Keys.parse(CLIENT_PRIVKEY)) + ] + + fake_client.on_get_events = on_get_events + identity = await look_up_client_app_identity( + client_id=CLIENT_ID, + nostr_client_factory=lambda: fake_client, + ) + + mock_verify_nip05.assert_called_once() + + assert identity is not None + assert identity.name == "Blue Drink" + assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED + assert identity.image_url == "https://bluedrink.com/image.png" + + +@patch.object(Nip05, "verify", new_callable=AsyncMock) +async def test_only_kind13195_no_label( + mock_verify_nip05: AsyncMock, test_client: QuartClient +) -> None: + mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED + fake_client = FakeNostrClient() + + async def on_get_events(filters: List[Filter], source: EventSource): + return [ + EventBuilder( + kind=Kind(13195), + content=json.dumps( + { + "name": "Green Drink", + "nip05": "_@greendrink.com", + "image": "https://greendrink.com/image.png", + "allowed_redirect_urls": ["https://greendrink.com/callback"], + } + ), + tags=[], + ).to_event(Keys.parse(CLIENT_PRIVKEY)) + ] + + fake_client.on_get_events = on_get_events + async with test_client.app.app_context(): + identity = await look_up_client_app_identity( + client_id=CLIENT_ID, + nostr_client_factory=lambda: fake_client, + ) + + mock_verify_nip05.assert_called_once() + + assert identity is not None + assert identity.name == "Green Drink" + assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED + assert identity.image_url == "https://greendrink.com/image.png" + assert identity.allowed_redirect_urls == ["https://greendrink.com/callback"] + assert identity.app_authority_verification is None + + +@patch.object(Nip05, "verify", new_callable=AsyncMock) +async def test_only_kind13195_with_label( + mock_verify_nip05: AsyncMock, test_client: QuartClient +) -> None: + mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED + fake_client = FakeNostrClient() + + id_event = EventBuilder( + kind=Kind(13195), + content=json.dumps( + { + "name": "Green Drink", + "nip05": "_@greendrink.com", + "image": "https://greendrink.com/image.png", + "allowed_redirect_urls": ["https://greendrink.com/callback"], + } + ), + tags=[], + ).to_event(Keys.parse(CLIENT_PRIVKEY)) + + async def on_get_events(filters: List[Filter], source: EventSource): + if Kind(13195) in filters[0].as_record().kinds: + return [id_event] + if Kind.from_enum(KindEnum.LABEL()) in filters[0].as_record().kinds: + return [ + EventBuilder.label("nip68.client_app", ["verified", "nip68.client_app"]) + .add_tags([Tag.event(id_event.id())]) + .to_event(Keys.parse(AUTHORITY_PRIVKEY)), + EventBuilder.metadata( + Metadata().set_name("Important Authority") + ).to_event(Keys.parse(AUTHORITY_PRIVKEY)), + ] + return [] + + fake_client.on_get_events = on_get_events + async with test_client.app.app_context(): + identity = await look_up_client_app_identity( + client_id=CLIENT_ID, + nostr_client_factory=lambda: fake_client, + ) + + mock_verify_nip05.assert_called_once() + + assert identity is not None + assert identity.name == "Green Drink" + assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED + assert identity.image_url == "https://greendrink.com/image.png" + assert identity.allowed_redirect_urls == ["https://greendrink.com/callback"] + assert identity.app_authority_verification is not None + assert identity.app_authority_verification.authority_name == "Important Authority" + assert ( + identity.app_authority_verification.authority_pubkey + == Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex() + ) + assert ( + identity.app_authority_verification.status == Nip68VerificationStatus.VERIFIED + ) + + +@patch.object(Nip05, "verify", new_callable=AsyncMock) +async def test_kind13195_with_revoked_label( + mock_verify_nip05: AsyncMock, test_client: QuartClient +) -> None: + mock_verify_nip05.return_value = Nip05VerificationStatus.VERIFIED + fake_client = FakeNostrClient() + + id_event = EventBuilder( + kind=Kind(13195), + content=json.dumps( + { + "name": "Yellow Drink", + "nip05": "_@yellowdrink.com", + "image": "https://yellowdrink.com/image.png", + "allowed_redirect_urls": ["https://yellowdrink.com/callback"], + } + ), + tags=[], + ).to_event(Keys.parse(CLIENT_PRIVKEY)) + + async def on_get_events(filters: List[Filter], source: EventSource): + if Kind(13195) in filters[0].as_record().kinds: + return [id_event] + if Kind.from_enum(KindEnum.LABEL()) in filters[0].as_record().kinds: + return [ + EventBuilder.label("nip68.client_app", ["revoked", "nip68.client_app"]) + .add_tags([Tag.event(id_event.id())]) + .to_event(Keys.parse(AUTHORITY_PRIVKEY)), + EventBuilder.metadata( + Metadata().set_name("Important Authority") + ).to_event(Keys.parse(AUTHORITY_PRIVKEY)), + ] + return [] + + fake_client.on_get_events = on_get_events + async with test_client.app.app_context(): + identity = await look_up_client_app_identity( + client_id=CLIENT_ID, + nostr_client_factory=lambda: fake_client, + ) + + mock_verify_nip05.assert_called_once() + + assert identity is not None + assert identity.name == "Yellow Drink" + assert identity.nip05.verification_status == Nip05VerificationStatus.VERIFIED + assert identity.image_url == "https://yellowdrink.com/image.png" + assert identity.allowed_redirect_urls == ["https://yellowdrink.com/callback"] + assert identity.app_authority_verification is not None + assert identity.app_authority_verification.authority_name == "Important Authority" + assert ( + identity.app_authority_verification.authority_pubkey + == Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex() + ) + assert identity.app_authority_verification.status == Nip68VerificationStatus.REVOKED diff --git a/nwc_backend/nostr/__tests__/fake_nostr_client.py b/nwc_backend/nostr/__tests__/fake_nostr_client.py new file mode 100644 index 0000000..5e2df44 --- /dev/null +++ b/nwc_backend/nostr/__tests__/fake_nostr_client.py @@ -0,0 +1,45 @@ +from typing import Any, Callable, Coroutine, List + +from nostr_sdk import Client +from nostr_sdk.nostr_ffi import Event, Filter +from nostr_sdk.nostr_sdk_ffi import EventSource, Output, SendEventOutput + + +class FakeNostrClient(Client): + def __init__(self, *args, **kwargs): + self.connected = False + self.sent_events = [] + self.last_filters = [] + self.added_relays: List[str] = [] + self.on_get_events: Callable[ + [List[Filter], EventSource], Coroutine[Any, Any, List[Event]] + ] = None + + async def connect(self): + self.connected = True + + async def disconnect(self): + self.connected = False + + async def add_relay(self, url: str) -> bool: + self.added_relays.append(url) + return True + + async def add_read_relay(self, url: str) -> bool: + self.added_relays.append(url) + return True + + async def get_events_of( + self, filters: List[Filter], source: EventSource + ) -> List[Event]: + self.last_filters = filters + if self.on_get_events: + return await self.on_get_events(filters, source) + return [] + + async def send_event(self, event: Event) -> SendEventOutput: + self.sent_events.append(event) + return SendEventOutput( + id=event.id(), + output=Output(success=self.added_relays, failed={}), + ) diff --git a/nwc_backend/nostr/client_app_identity_lookup.py b/nwc_backend/nostr/client_app_identity_lookup.py index d0589be..4056c9b 100644 --- a/nwc_backend/nostr/client_app_identity_lookup.py +++ b/nwc_backend/nostr/client_app_identity_lookup.py @@ -5,10 +5,11 @@ from dataclasses import dataclass from datetime import timedelta from enum import Enum -from typing import Optional +from typing import Callable, List, Optional from urllib.parse import urlparse from nostr_sdk import ( + Alphabet, Client, Event, EventSource, @@ -16,9 +17,13 @@ Kind, KindEnum, Metadata, + Nip19Profile, PublicKey, + SingleLetterTag, + TagKind, verify_nip05, ) +from quart import current_app from nwc_backend.exceptions import InvalidClientIdException @@ -29,6 +34,20 @@ class Nip05VerificationStatus(Enum): UNKNOWN = "UNKNOWN" +class Nip68VerificationStatus(Enum): + VERIFIED = "VERIFIED" + REVOKED = "REVOKED" + NONE = "NONE" + + +@dataclass +class Nip68Verification: + status: Nip68VerificationStatus + authority_pubkey: str + authority_name: str + revoked_at: Optional[int] + + @dataclass class Nip05: domain: str @@ -75,6 +94,7 @@ class ClientAppInfo: nip05: Optional[Nip05] = None display_name: Optional[str] = None allowed_redirect_urls: Optional[list[str]] = None + app_authority_verification: Optional[Nip68Verification] = None def is_redirect_url_allowed(self, redirect_url: str) -> bool: if not self.allowed_redirect_urls: @@ -96,7 +116,10 @@ def is_redirect_url_allowed(self, redirect_url: str) -> bool: return False -async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo]: +async def look_up_client_app_identity( + client_id: str, + nostr_client_factory: Callable[[], Client] = Client, +) -> Optional[ClientAppInfo]: try: [client_pubkey, relay_url] = client_id.split(" ") except ValueError: @@ -112,7 +135,7 @@ async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo] except Exception: raise InvalidClientIdException("Invalid public key in client_id.") - client = Client() + client = nostr_client_factory() await client.add_relay(relay_url) await client.connect() @@ -136,7 +159,10 @@ async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo] return None client_app_info = await _look_up_from_kind_13195( - events=events, client_pubkey=client_pubkey, relay_url=relay_url + events=events, + client_pubkey=client_pubkey, + relay_url=relay_url, + nostr_client_factory=nostr_client_factory, ) if client_app_info: logging.debug("Found 13195 for client_id %s", client_id) @@ -149,25 +175,34 @@ async def look_up_client_app_identity(client_id: str) -> Optional[ClientAppInfo] async def _look_up_from_kind_13195( - events: list[Event], client_pubkey: PublicKey, relay_url: str + events: List[Event], + client_pubkey: PublicKey, + relay_url: str, + nostr_client_factory: Callable[[], Client], ) -> Optional[ClientAppInfo]: - events_13195 = [event for event in events if event.kind().as_u16 == 13195] - if events_13195: - event = events_13195[0] - event.verify_signature() - content = json.loads(event.content()) - return ClientAppInfo( - pubkey=client_pubkey, - identity_relay=relay_url, - name=content.get("name"), - image_url=content.get("image"), - nip05=await Nip05.from_nip05_address( - nip05_address=content.get("nip05"), pubkey=client_pubkey - ), - display_name=content.get("name"), - allowed_redirect_urls=content.get("allowed_redirect_urls"), - ) - return None + events_13195 = [event for event in events if event.kind().as_u16() == 13195] + if not events_13195: + return None + + event = events_13195[0] + if not event.verify_signature(): + raise InvalidClientIdException("Invalid signature in 13195 event.") + + content = json.loads(event.content()) + return ClientAppInfo( + pubkey=client_pubkey, + identity_relay=relay_url, + name=content.get("name"), + image_url=content.get("image"), + nip05=await Nip05.from_nip05_address( + nip05_address=content.get("nip05"), pubkey=client_pubkey + ), + display_name=content.get("name"), + allowed_redirect_urls=content.get("allowed_redirect_urls"), + app_authority_verification=await _check_app_authorities( + event, nostr_client_factory + ), + ) async def _look_up_from_kind_0( @@ -176,7 +211,10 @@ async def _look_up_from_kind_0( [event] = [ event for event in events if event.kind().as_enum() == KindEnum.METADATA() ] - event.verify_signature() + + if not event.verify_signature(): + raise InvalidClientIdException("Invalid signature in metadata event.") + metadata = Metadata.from_json(event.content()) return ClientAppInfo( pubkey=client_pubkey, @@ -188,4 +226,109 @@ async def _look_up_from_kind_0( ), display_name=metadata.get_display_name(), allowed_redirect_urls=None, + app_authority_verification=None, + ) + + +async def _check_app_authorities( + identity_event: Event, + nostr_client_factory: Callable[[], Client] = Client, +) -> Optional[Nip68Verification]: + registration_authorities = current_app.config.get("CLIENT_APP_AUTHORITIES") + if not registration_authorities: + return None + + try: + authority_nprofiles = [ + Nip19Profile.from_bech32(authority) + for authority in registration_authorities + ] + except Exception: + logging.exception("Invalid NIP19 profile in CLIENT_APP_AUTHORITIES.") + return None + + client = nostr_client_factory() + for nprofile in authority_nprofiles: + for relay in nprofile.relays(): + await client.add_read_relay(relay) + + authority_pubkeys = [nprofile.public_key() for nprofile in authority_nprofiles] + + await client.connect() + label_filter = ( + Filter() + .authors(authority_pubkeys) + .kinds( + [ + Kind.from_enum(KindEnum.LABEL()), # pyre-ignore[6] + Kind.from_enum(KindEnum.METADATA()), # pyre-ignore[6] + ] + ) + .custom_tag(SingleLetterTag.uppercase(Alphabet.L), ["nip68.client_app"]) + .event(identity_event.id()) + ) + metadata_filter = ( + Filter() + .authors(authority_pubkeys) + .kinds( + [ + Kind.from_enum(KindEnum.METADATA()), # pyre-ignore[6] + ] + ) + ) + source = EventSource.relays(timeout=timedelta(seconds=10)) + verification_and_metadata_events = await client.get_events_of( + filters=[label_filter, metadata_filter], source=source ) + await client.disconnect() + + verification_events = [ + event + for event in verification_and_metadata_events + if event.kind().as_enum() == KindEnum.LABEL() + ] + + metadata_events_by_pubkey = { + event.author().to_hex(): event + for event in verification_and_metadata_events + if event.kind().as_enum() == KindEnum.METADATA() + } + + def authority_name_for_pubkey(pubkey: str) -> str: + authority_name = "Default App Authority" + if metadata_events_by_pubkey.get(pubkey): + authority_name = ( + Metadata.from_json( + metadata_events_by_pubkey[pubkey].content() + ).get_name() + or authority_name + ) + return authority_name + + # If any verifications were revoked, prioritize that status above all others. + for event in verification_events: + status = event.get_tag_content( + TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.L)) + ) + if status and status.lower() == "revoked": + return Nip68Verification( + status=Nip68VerificationStatus.REVOKED, + authority_pubkey=event.author().to_hex(), + authority_name=authority_name_for_pubkey(event.author().to_hex()), + revoked_at=event.created_at(), + ) + + # If any verifications were confirmed, return the first one. + for event in verification_events: + status = event.get_tag_content( + TagKind.SINGLE_LETTER(SingleLetterTag.lowercase(Alphabet.L)) + ) + if status and status.lower() == "verified": + return Nip68Verification( + status=Nip68VerificationStatus.VERIFIED, + authority_pubkey=event.author().to_hex(), + authority_name=authority_name_for_pubkey(event.author().to_hex()), + revoked_at=None, + ) + + return None