Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add app authority label verification. #204

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,4 @@ cython_debug/
#.idea/

.quartenv
.vscode/
3 changes: 2 additions & 1 deletion nwc-frontend/src/hooks/useAppInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
3 changes: 2 additions & 1 deletion nwc-frontend/src/permissions/PermissionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ export const PermissionsPage = () => {
{appInfo && (
<>
{appInfo.name}
{appInfo.verified && (
{appInfo.nip05Verified && (
// TODO: Add NIP-68 verification status
<VerifiedBadge>
<Icon name="CheckmarkCircleTier3" width={20} />
</VerifiedBadge>
Expand Down
3 changes: 2 additions & 1 deletion nwc-frontend/src/permissions/PersonalizePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export const PersonalizePage = ({
{appInfo && (
<>
{appInfo.name}
{appInfo.verified && (
{appInfo.nip05Verified && (
// TODO: Add NIP-68 verification status
<VerifiedBadge>
<Icon name="CheckmarkCircleTier3" width={20} />
</VerifiedBadge>
Expand Down
7 changes: 6 additions & 1 deletion nwc-frontend/src/types/AppInfo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export interface AppInfo {
clientId: string;
name: string;
verified: boolean;
nip05Verified: boolean;
domain: string;
avatar: string;
nip68Verification?: {
status: string;
authorityName: string;
authorityPubKey: string;
} | null;
}
11 changes: 10 additions & 1 deletion nwc_backend/api_handlers/client_app_lookup_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,25 @@ async def get_client_app() -> Response:
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
Expand Down
6 changes: 6 additions & 0 deletions nwc_backend/configs/local_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import secrets
from typing import List

# DATABASE_URI: str = "postgresql+asyncpg://:@127.0.0.1:5432/nwc"
DATABASE_URI: str = "sqlite+aiosqlite:///" + os.path.join(
Expand Down Expand Up @@ -35,3 +36,8 @@
"execute_quote",
"pay_to_address",
]

# NIP-68 client app authorities which can verify app identity events.
CLIENT_APP_AUTHORITIES: List[str] = [
# "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol
]
6 changes: 6 additions & 0 deletions nwc_backend/configs/local_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import secrets
from typing import List

DATABASE_URI: str = "sqlite+aiosqlite:///" + os.path.join(
os.getcwd(), "instance", "nwc.sqlite"
Expand Down Expand Up @@ -34,3 +35,8 @@
"execute_quote",
"pay_to_address",
]

# NIP-68 client app authorities which can verify app identity events.
CLIENT_APP_AUTHORITIES: List[str] = [
# "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol
]
4 changes: 4 additions & 0 deletions nwc_backend/configs/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@
"execute_quote",
"pay_to_address",
]

CLIENT_APP_AUTHORITIES = [
"nprofile1qqstg4syz8qyk9xeyp5j7haaw9nz67a6wzt80tmu5vn5g4ckpxlagvqpp4mhxue69uhkummn9ekx7mqnegwyj" # Fake authority at nos.lol
]
232 changes: 232 additions & 0 deletions nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py
Original file line number Diff line number Diff line change
@@ -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("[email protected]")
.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 is not None
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": "[email protected]",
"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 is not None
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": "[email protected]",
"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):
filter_kinds = filters[0].as_record().kinds or []
if Kind(13195) in filter_kinds:
return [id_event]
if Kind.from_enum(KindEnum.LABEL()) in filter_kinds: # pyre-ignore[6]
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 is not None
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"]
nip68 = identity.app_authority_verification
assert nip68 is not None
assert nip68.authority_name == "Important Authority"
assert nip68.authority_pubkey == Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex()
assert nip68.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": "[email protected]",
"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):
filter_kinds = filters[0].as_record().kinds or []
if Kind(13195) in filter_kinds:
return [id_event]
if Kind.from_enum(KindEnum.LABEL()) in filter_kinds: # pyre-ignore[6]
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 is not None
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"]
nip68 = identity.app_authority_verification
assert nip68 is not None
assert nip68.authority_name == "Important Authority"
assert nip68.authority_pubkey == Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex()
assert nip68.status == Nip68VerificationStatus.REVOKED
45 changes: 45 additions & 0 deletions nwc_backend/nostr/__tests__/fake_nostr_client.py
Original file line number Diff line number Diff line change
@@ -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={}),
)
Loading
Loading