Skip to content

Commit

Permalink
Add app authority label verification.
Browse files Browse the repository at this point in the history
Implements the protocol as described by NIP-68 and returns verification state to the frontend. Currently, the frontend doesn't actually do anything with it, but we should probably put more thought into verification badge UI on the frontend.
  • Loading branch information
jklein24 committed Oct 13, 2024
1 parent 832aab2 commit f982e9f
Show file tree
Hide file tree
Showing 12 changed files with 482 additions and 28 deletions.
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
9 changes: 8 additions & 1 deletion nwc-frontend/src/types/AppInfo.ts
Original file line number Diff line number Diff line change
@@ -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;
}
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 @@ -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
Expand Down
5 changes: 5 additions & 0 deletions nwc_backend/configs/local_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@
"execute_quote",
"pay_to_address",
]

# NIP-68 client app authorities which can verify app identity events.
CLIENT_APP_AUTHORITIES = [

Check failure on line 40 in nwc_backend/configs/local_dev.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Missing global annotation [5]

Globally accessible variable `CLIENT_APP_AUTHORITIES` has no type specified.
# "nprofile1qqstse98yvaykl3k2yez3732tmsc9vaq8c3uhex0s4qp4dl8fczmp9spp4mhxue69uhkummn9ekx7mq26saje" # Lightspark at nos.lol
]
5 changes: 5 additions & 0 deletions nwc_backend/configs/local_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@
"execute_quote",
"pay_to_address",
]

# NIP-68 client app authorities which can verify app identity events.
CLIENT_APP_AUTHORITIES = [

Check failure on line 39 in nwc_backend/configs/local_docker.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Missing global annotation [5]

Globally accessible variable `CLIENT_APP_AUTHORITIES` has no type specified.
# "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.verification_status == Nip05VerificationStatus.VERIFIED

Check failure on line 75 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Undefined attribute [16]

Optional type has no attribute `verification_status`.
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.verification_status == Nip05VerificationStatus.VERIFIED

Check failure on line 113 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Undefined attribute [16]

Optional type has no attribute `verification_status`.
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):
if Kind(13195) in filters[0].as_record().kinds:

Check failure on line 140 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsupported operand [58]

`in` is not supported for right operand type `typing.Optional[List[nostr_sdk.nostr_ffi.Kind]]`.
return [id_event]
if Kind.from_enum(KindEnum.LABEL()) in filters[0].as_record().kinds:

Check failure on line 142 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsupported operand [58]

`in` is not supported for right operand type `typing.Optional[List[nostr_sdk.nostr_ffi.Kind]]`.
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

Check failure on line 164 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Undefined attribute [16]

Optional type has no attribute `verification_status`.
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

Check failure on line 170 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Undefined attribute [16]

Optional type has no attribute `authority_pubkey`.
== Keys.parse(AUTHORITY_PRIVKEY).public_key().to_hex()
)
assert (
identity.app_authority_verification.status == Nip68VerificationStatus.VERIFIED

Check failure on line 174 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Undefined attribute [16]

Optional type has no attribute `status`.
)


@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):
if Kind(13195) in filters[0].as_record().kinds:

Check failure on line 199 in nwc_backend/nostr/__tests__/client_app_identity_lookup_test.py

View workflow job for this annotation

GitHub Actions / lint-and-test

Unsupported operand [58]

`in` is not supported for right operand type `typing.Optional[List[nostr_sdk.nostr_ffi.Kind]]`.
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
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

0 comments on commit f982e9f

Please sign in to comment.