diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9999a3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +.github/ +.DS_Store diff --git a/.github/prompts/basics.prompt.md b/.github/prompts/basics.prompt.md new file mode 100644 index 0000000..34817a5 --- /dev/null +++ b/.github/prompts/basics.prompt.md @@ -0,0 +1,15 @@ +nostr-vibed is a modern web application starter built for experimenting with the Nostr protocol and related technologies. + +This project uses: +- **Next.js (App Router):** React-based framework for server-side rendering, routing, and API routes. +- **TailwindCSS:** Utility-first CSS framework for rapid UI development. +- **shadcn/ui:** Accessible, customizable React UI components built on top of Radix UI primitives. +- **next-themes:** Theme switching (light/dark) with system preference support. +- **lucide-react:** Icon library for modern SVG icons. +- **nostr-tools:** Utilities for working with the Nostr protocol (events, keys, relays, etc). + +Features: +- Clean, responsive layout with a customizable header and theme toggle. +- Ready-to-use component structure for rapid prototyping. +- Utility helpers and sensible defaults for styling and state management. +- Easily extendable for building Nostr dashboards or experiments. \ No newline at end of file diff --git a/.github/prompts/nostr-nip-46.prompt.md b/.github/prompts/nostr-nip-46.prompt.md new file mode 100644 index 0000000..2f62f22 --- /dev/null +++ b/.github/prompts/nostr-nip-46.prompt.md @@ -0,0 +1,226 @@ +NIP-46 +====== + +Nostr Remote Signing +-------------------- + +## Changes + +`remote-signer-key` is introduced, passed in bunker url, clients must differentiate between `remote-signer-pubkey` and `user-pubkey`, must call `get_public_key` after connect, nip05 login is removed, create_account moved to another NIP. + +## Rationale + +Private keys should be exposed to as few systems - apps, operating systems, devices - as possible as each system adds to the attack surface. + +This NIP describes a method for 2-way communication between a remote signer and a Nostr client. The remote signer could be, for example, a hardware device dedicated to signing Nostr events, while the client is a normal Nostr client. + +## Terminology + +- **user**: A person that is trying to use Nostr. +- **client**: A user-facing application that _user_ is looking at and clicking buttons in. This application will send requests to _remote-signer_. +- **remote-signer**: A daemon or server running somewhere that will answer requests from _client_, also known as "bunker". +- **client-keypair/pubkey**: The keys generated by _client_. Used to encrypt content and communicate with _remote-signer_. +- **remote-signer-keypair/pubkey**: The keys used by _remote-signer_ to encrypt content and communicate with _client_. This keypair MAY be same as _user-keypair_, but not necessarily. +- **user-keypair/pubkey**: The actual keys representing _user_ (that will be used to sign events in response to `sign_event` requests, for example). The _remote-signer_ generally has control over these keys. + +All pubkeys specified in this NIP are in hex format. + +## Overview + +1. _client_ generates `client-keypair`. This keypair doesn't need to be communicated to _user_ since it's largely disposable. _client_ might choose to store it locally and they should delete it on logout; +2. A connection is established (see below), _remote-signer_ learns `client-pubkey`, _client_ learns `remote-signer-pubkey`. +3. _client_ uses `client-keypair` to send requests to _remote-signer_ by `p`-tagging and encrypting to `remote-signer-pubkey`; +4. _remote-signer_ responds to _client_ by `p`-tagging and encrypting to the `client-pubkey`. +5. _client_ requests `get_public_key` to learn `user-pubkey`. + +## Initiating a connection + +There are two ways to initiate a connection: + +### Direct connection initiated by _remote-signer_ + +_remote-signer_ provides connection token in the form: + +``` +bunker://?relay=&relay=&secret= +``` + +_user_ passes this token to _client_, which then sends `connect` request to _remote-signer_ via the specified relays. Optional secret can be used for single successfully established connection only, _remote-signer_ SHOULD ignore new attempts to establish connection with old secret. + +### Direct connection initiated by the _client_ + +_client_ provides a connection token using `nostrconnect://` as the protocol, and `client-pubkey` as the origin. Additional information should be passed as query parameters: + +- `relay` (required) - one or more relay urls on which the _client_ is listening for responses from the _remote-signer_. +- `secret` (required) - a short random string that the _remote-signer_ should return as the `result` field of its response. +- `perms` (optional) - a comma-separated list of permissions the _client_ is requesting be approved by the _remote-signer_ +- `name` (optional) - the name of the _client_ application +- `url` (optional) - the canonical url of the _client_ application +- `image` (optional) - a small image representing the _client_ application + +Here's an example: + +``` +nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&perms=nip44_encrypt%2Cnip44_decrypt%2Csign_event%3A13%2Csign_event%3A14%2Csign_event%3A1059&name=My+Client&secret=0s8j2djs&relay=wss%3A%2F%2Frelay2.example2.com +``` + +_user_ passes this token to _remote-signer_, which then sends `connect` *response* event to the `client-pubkey` via the specified relays. Client discovers `remote-signer-pubkey` from connect response author. `secret` value MUST be provided to avoid connection spoofing, _client_ MUST validate the `secret` returned by `connect` response. + +## Request Events `kind: 24133` + +```jsonc +{ + "kind": 24133, + "pubkey": , + "content": )>, + "tags": [["p", ]], +} +``` + +The `content` field is a JSON-RPC-like message that is [NIP-44](44.md) encrypted and has the following structure: + +```jsonc +{ + "id": , + "method": , + "params": [array_of_strings] +} +``` + +- `id` is a random string that is a request ID. This same ID will be sent back in the response payload. +- `method` is the name of the method/command (detailed below). +- `params` is a positional array of string parameters. + +### Methods/Commands + +Each of the following are methods that the _client_ sends to the _remote-signer_. + +| Command | Params | Result | +| ------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------- | +| `connect` | `[, , ]` | "ack" OR `` | +| `sign_event` | `[<{kind, content, tags, created_at}>]` | `json_stringified()` | +| `ping` | `[]` | "pong" | +| `get_public_key` | `[]` | `` | +| `nip04_encrypt` | `[, ]` | `` | +| `nip04_decrypt` | `[, ]` | `` | +| `nip44_encrypt` | `[<third_party_pubkey>, <plaintext_to_encrypt>]` | `<nip44_ciphertext>` | +| `nip44_decrypt` | `[<third_party_pubkey>, <nip44_ciphertext_to_decrypt>]` | `<plaintext>` | + +### Requested permissions + +The `connect` method may be provided with `optional_requested_permissions` for user convenience. The permissions are a comma-separated list of `method[:params]`, i.e. `nip44_encrypt,sign_event:4` meaning permissions to call `nip44_encrypt` and to call `sign_event` with `kind:4`. Optional parameter for `sign_event` is the kind number, parameters for other methods are to be defined later. Same permission format may be used for `perms` field of `metadata` in `nostrconnect://` string. + +## Response Events `kind:24133` + +```json +{ + "id": <id>, + "kind": 24133, + "pubkey": <remote-signer-pubkey>, + "content": <nip44(<response>)>, + "tags": [["p", <client-pubkey>]], + "created_at": <unix timestamp in seconds> +} +``` + +The `content` field is a JSON-RPC-like message that is [NIP-44](44.md) encrypted and has the following structure: + +```json +{ + "id": <request_id>, + "result": <results_string>, + "error": <optional_error_string> +} +``` + +- `id` is the request ID that this response is for. +- `results` is a string of the result of the call (this can be either a string or a JSON stringified object) +- `error`, _optionally_, it is an error in string form, if any. Its presence indicates an error with the request. + +## Example flow for signing an event + +- `remote-signer-pubkey` is `fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52` +- `user-pubkey` is also `fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52` +- `client-pubkey` is `eff37350d839ce3707332348af4549a96051bd695d3223af4aabce4993531d86` + +### Signature request + +```jsonc +{ + "kind": 24133, + "pubkey": "eff37350d839ce3707332348af4549a96051bd695d3223af4aabce4993531d86", + "content": nip44({ + "id": <random_string>, + "method": "sign_event", + "params": [json_stringified(<{ + content: "Hello, I'm signing remotely", + kind: 1, + tags: [], + created_at: 1714078911 + }>)] + }), + "tags": [["p", "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"]], // p-tags the remote-signer-pubkey +} +``` + +### Response event + +```jsonc +{ + "kind": 24133, + "pubkey": "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", + "content": nip44({ + "id": <random_string>, + "result": json_stringified(<signed-event>) + }), + "tags": [["p", "eff37350d839ce3707332348af4549a96051bd695d3223af4aabce4993531d86"]], // p-tags the client-pubkey +} +``` + +### Diagram + +![signing-example](https://i.nostr.build/P3gW.png) + + +## Auth Challenges + +An Auth Challenge is a response that a _remote-signer_ can send back when it needs the _user_ to authenticate via other means. The response `content` object will take the following form: + +```json +{ + "id": <request_id>, + "result": "auth_url", + "error": <URL_to_display_to_end_user> +} +``` + +_client_ should display (in a popup or new tab) the URL from the `error` field and then subscribe/listen for another response from the _remote-signer_ (reusing the same request ID). This event will be sent once the user authenticates in the other window (or will never arrive if the user doesn't authenticate). + +### Example event signing request with auth challenge + +![signing-example-with-auth-challenge](https://i.nostr.build/W3aj.png) + +## Appendix + +### Announcing _remote-signer_ metadata + +_remote-signer_ MAY publish it's metadata by using [NIP-05](05.md) and [NIP-89](89.md). With NIP-05, a request to `<remote-signer>/.well-known/nostr.json?name=_` MAY return this: +```jsonc +{ + "names":{ + "_": <remote-signer-app-pubkey>, + }, + "nip46": { + "relays": ["wss://relay1","wss://relay2"...], + "nostrconnect_url": "https://remote-signer-domain.example/<nostrconnect>" + } +} +``` + +The `<remote-signer-app-pubkey>` MAY be used to verify the domain from _remote-signer_'s NIP-89 event (see below). `relays` SHOULD be used to construct a more precise `nostrconnect://` string for the specific `remote-signer`. `nostrconnect_url` template MAY be used to redirect users to _remote-signer_'s connection flow by replacing `<nostrconnect>` placeholder with an actual `nostrconnect://` string. + +### Remote signer discovery via NIP-89 + +_remote-signer_ MAY publish a NIP-89 `kind: 31990` event with `k` tag of `24133`, which MAY also include one or more `relay` tags and MAY include `nostrconnect_url` tag. The semantics of `relay` and `nostrconnect_url` tags are the same as in the section above. + +_client_ MAY improve UX by discovering _remote-signers_ using their `kind: 31990` events. _client_ MAY then pre-generate `nostrconnect://` strings for the _remote-signers_, and SHOULD in that case verify that `kind: 31990` event's author is mentioned in signer's `nostr.json?name=_` file as `<remote-signer-app-pubkey>`. \ No newline at end of file diff --git a/.github/prompts/nostr-nip01.prompt.md b/.github/prompts/nostr-nip01.prompt.md new file mode 100644 index 0000000..08a6d98 --- /dev/null +++ b/.github/prompts/nostr-nip01.prompt.md @@ -0,0 +1,177 @@ +NIP-01 +====== + +Basic protocol flow description +------------------------------- + +`draft` `mandatory` + +This NIP defines the basic protocol that should be implemented by everybody. New NIPs may add new optional (or mandatory) fields and messages and features to the structures and flows described here. + +## Events and signatures + +Each user has a keypair. Signatures, public key, and encodings are done according to the [Schnorr signatures standard for the curve `secp256k1`](https://bips.xyz/340). + +The only object type that exists is the `event`, which has the following format on the wire: + +```jsonc +{ + "id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>, + "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, + "created_at": <unix timestamp in seconds>, + "kind": <integer between 0 and 65535>, + "tags": [ + [<arbitrary string>...], + // ... + ], + "content": <arbitrary string>, + "sig": <64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field> +} +``` + +To obtain the `event.id`, we `sha256` the serialized event. The serialization is done over the UTF-8 JSON-serialized string (which is described below) of the following structure: + +``` +[ + 0, + <pubkey, as a lowercase hex string>, + <created_at, as a number>, + <kind, as a number>, + <tags, as an array of arrays of non-null strings>, + <content, as a string> +] +``` + +To prevent implementation differences from creating a different event ID for the same event, the following rules MUST be followed while serializing: +- UTF-8 should be used for encoding. +- Whitespace, line breaks or other unnecessary formatting should not be included in the output JSON. +- The following characters in the content field must be escaped as shown, and all other characters must be included verbatim: + - A line break (`0x0A`), use `\n` + - A double quote (`0x22`), use `\"` + - A backslash (`0x5C`), use `\\` + - A carriage return (`0x0D`), use `\r` + - A tab character (`0x09`), use `\t` + - A backspace, (`0x08`), use `\b` + - A form feed, (`0x0C`), use `\f` + +### Tags + +Each tag is an array of one or more strings, with some conventions around them. Take a look at the example below: + +```jsonc +{ + "tags": [ + ["e", "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", "wss://nostr.example.com"], + ["p", "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca"], + ["a", "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:abcd", "wss://nostr.example.com"], + ["alt", "reply"], + // ... + ], + // ... +} +``` + +The first element of the tag array is referred to as the tag _name_ or _key_ and the second as the tag _value_. So we can safely say that the event above has an `e` tag set to `"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"`, an `alt` tag set to `"reply"` and so on. All elements after the second do not have a conventional name. + +This NIP defines 3 standard tags that can be used across all event kinds with the same meaning. They are as follows: + +- The `e` tag, used to refer to an event: `["e", <32-bytes lowercase hex of the id of another event>, <recommended relay URL, optional>, <32-bytes lowercase hex of the author's pubkey, optional>]` +- The `p` tag, used to refer to another user: `["p", <32-bytes lowercase hex of a pubkey>, <recommended relay URL, optional>]` +- The `a` tag, used to refer to an addressable or replaceable event + - for an addressable event: `["a", "<kind integer>:<32-bytes lowercase hex of a pubkey>:<d tag value>", <recommended relay URL, optional>]` + - for a normal replaceable event: `["a", "<kind integer>:<32-bytes lowercase hex of a pubkey>:", <recommended relay URL, optional>]` (note: include the trailing colon) + +As a convention, all single-letter (only english alphabet letters: a-z, A-Z) key tags are expected to be indexed by relays, such that it is possible, for example, to query or subscribe to events that reference the event `"5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"` by using the `{"#e": ["5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"]}` filter. Only the first value in any given tag is indexed. + +### Kinds + +Kinds specify how clients should interpret the meaning of each event and the other fields of each event (e.g. an `"r"` tag may have a meaning in an event of kind 1 and an entirely different meaning in an event of kind 10002). Each NIP may define the meaning of a set of kinds that weren't defined elsewhere. [NIP-10](10.md), for instance, especifies the `kind:1` text note for social media applications. + +This NIP defines one basic kind: + +- `0`: **user metadata**: the `content` is set to a stringified JSON object `{name: <nickname or full name>, about: <short bio>, picture: <url of the image>}` describing the user who created the event. [Extra metadata fields](24.md#kind-0) may be set. A relay may delete older events once it gets a new one for the same pubkey. + +And also a convention for kind ranges that allow for easier experimentation and flexibility of relay implementation: + +- for kind `n` such that `1000 <= n < 10000 || 4 <= n < 45 || n == 1 || n == 2`, events are **regular**, which means they're all expected to be stored by relays. +- for kind `n` such that `10000 <= n < 20000 || n == 0 || n == 3`, events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event MUST be stored by relays, older versions MAY be discarded. +- for kind `n` such that `20000 <= n < 30000`, events are **ephemeral**, which means they are not expected to be stored by relays. +- for kind `n` such that `30000 <= n < 40000`, events are **addressable** by their `kind`, `pubkey` and `d` tag value -- which means that, for each combination of `kind`, `pubkey` and the `d` tag value, only the latest event MUST be stored by relays, older versions MAY be discarded. + +In case of replaceable events with the same timestamp, the event with the lowest id (first in lexical order) should be retained, and the other discarded. + +When answering to `REQ` messages for replaceable events such as `{"kinds":[0],"authors":[<hex-key>]}`, even if the relay has more than one version stored, it SHOULD return just the latest one. + +These are just conventions and relay implementations may differ. + +## Communication between clients and relays + +Relays expose a websocket endpoint to which clients can connect. Clients SHOULD open a single websocket connection to each relay and use it for all their subscriptions. Relays MAY limit number of connections from specific IP/client/etc. + +### From client to relay: sending events and creating subscriptions + +Clients can send 3 types of messages, which must be JSON arrays, according to the following patterns: + + * `["EVENT", <event JSON as defined above>]`, used to publish events. + * `["REQ", <subscription_id>, <filters1>, <filters2>, ...]`, used to request events and subscribe to new updates. + * `["CLOSE", <subscription_id>]`, used to stop previous subscriptions. + +`<subscription_id>` is an arbitrary, non-empty string of max length 64 chars. It represents a subscription per connection. Relays MUST manage `<subscription_id>`s independently for each WebSocket connection. `<subscription_id>`s are not guaranteed to be globally unique. + +`<filtersX>` is a JSON object that determines what events will be sent in that subscription, it can have the following attributes: + +```json +{ + "ids": <a list of event ids>, + "authors": <a list of lowercase pubkeys, the pubkey of an event must be one of these>, + "kinds": <a list of a kind numbers>, + "#<single-letter (a-zA-Z)>": <a list of tag values, for #e — a list of event ids, for #p — a list of pubkeys, etc.>, + "since": <an integer unix timestamp in seconds. Events must have a created_at >= to this to pass>, + "until": <an integer unix timestamp in seconds. Events must have a created_at <= to this to pass>, + "limit": <maximum number of events relays SHOULD return in the initial query> +} +``` + +Upon receiving a `REQ` message, the relay SHOULD return events that match the filter. Any new events it receives SHOULD be sent to that same websocket until the connection is closed, a `CLOSE` event is received with the same `<subscription_id>`, or a new `REQ` is sent using the same `<subscription_id>` (in which case a new subscription is created, replacing the old one). + +Filter attributes containing lists (`ids`, `authors`, `kinds` and tag filters like `#e`) are JSON arrays with one or more values. At least one of the arrays' values must match the relevant field in an event for the condition to be considered a match. For scalar event attributes such as `authors` and `kind`, the attribute from the event must be contained in the filter list. In the case of tag attributes such as `#e`, for which an event may have multiple values, the event and filter condition values must have at least one item in common. + +The `ids`, `authors`, `#e` and `#p` filter lists MUST contain exact 64-character lowercase hex values. + +The `since` and `until` properties can be used to specify the time range of events returned in the subscription. If a filter includes the `since` property, events with `created_at` greater than or equal to `since` are considered to match the filter. The `until` property is similar except that `created_at` must be less than or equal to `until`. In short, an event matches a filter if `since <= created_at <= until` holds. + +All conditions of a filter that are specified must match for an event for it to pass the filter, i.e., multiple conditions are interpreted as `&&` conditions. + +A `REQ` message may contain multiple filters. In this case, events that match any of the filters are to be returned, i.e., multiple filters are to be interpreted as `||` conditions. + +The `limit` property of a filter is only valid for the initial query and MUST be ignored afterwards. When `limit: n` is present it is assumed that the events returned in the initial query will be the last `n` events ordered by the `created_at`. Newer events should appear first, and in the case of ties the event with the lowest id (first in lexical order) should be first. It is safe to return less events than `limit` specifies, but it is expected that relays do not return (much) more events than requested so clients don't get unnecessarily overwhelmed by data. + +### From relay to client: sending events and notices + +Relays can send 5 types of messages, which must also be JSON arrays, according to the following patterns: + + * `["EVENT", <subscription_id>, <event JSON as defined above>]`, used to send events requested by clients. + * `["OK", <event_id>, <true|false>, <message>]`, used to indicate acceptance or denial of an `EVENT` message. + * `["EOSE", <subscription_id>]`, used to indicate the _end of stored events_ and the beginning of events newly received in real-time. + * `["CLOSED", <subscription_id>, <message>]`, used to indicate that a subscription was ended on the server side. + * `["NOTICE", <message>]`, used to send human-readable error messages or other things to clients. + +This NIP defines no rules for how `NOTICE` messages should be sent or treated. + +- `EVENT` messages MUST be sent only with a subscription ID related to a subscription previously initiated by the client (using the `REQ` message above). +- `OK` messages MUST be sent in response to `EVENT` messages received from clients, they must have the 3rd parameter set to `true` when an event has been accepted by the relay, `false` otherwise. The 4th parameter MUST always be present, but MAY be an empty string when the 3rd is `true`, otherwise it MUST be a string formed by a machine-readable single-word prefix followed by a `:` and then a human-readable message. Some examples: + * `["OK", "b1a649ebe8...", true, ""]` + * `["OK", "b1a649ebe8...", true, "pow: difficulty 25>=24"]` + * `["OK", "b1a649ebe8...", true, "duplicate: already have this event"]` + * `["OK", "b1a649ebe8...", false, "blocked: you are banned from posting here"]` + * `["OK", "b1a649ebe8...", false, "blocked: please register your pubkey at https://my-expensive-relay.example.com"]` + * `["OK", "b1a649ebe8...", false, "rate-limited: slow down there chief"]` + * `["OK", "b1a649ebe8...", false, "invalid: event creation date is too far off from the current time"]` + * `["OK", "b1a649ebe8...", false, "pow: difficulty 26 is less than 30"]` + * `["OK", "b1a649ebe8...", false, "restricted: not allowed to write."]` + * `["OK", "b1a649ebe8...", false, "error: could not connect to the database"]` +- `CLOSED` messages MUST be sent in response to a `REQ` when the relay refuses to fulfill it. It can also be sent when a relay decides to kill a subscription on its side before a client has disconnected or sent a `CLOSE`. This message uses the same pattern of `OK` messages with the machine-readable prefix and human-readable message. Some examples: + * `["CLOSED", "sub1", "unsupported: filter contains unknown elements"]` + * `["CLOSED", "sub1", "error: could not connect to the database"]` + * `["CLOSED", "sub1", "error: shutting down idle subscription"]` +- The standardized machine-readable prefixes for `OK` and `CLOSED` are: `duplicate`, `pow`, `blocked`, `rate-limited`, `invalid`, `restricted`, and `error` for when none of that fits. \ No newline at end of file diff --git a/.github/prompts/nostr-nip07.prompt.md b/.github/prompts/nostr-nip07.prompt.md new file mode 100644 index 0000000..ae566bb --- /dev/null +++ b/.github/prompts/nostr-nip07.prompt.md @@ -0,0 +1,32 @@ +NIP-07 +====== + +`window.nostr` capability for web browsers +------------------------------------------ + +`draft` `optional` + +The `window.nostr` object may be made available by web browsers or extensions and websites or web-apps may make use of it after checking its availability. + +That object must define the following methods: + +``` +async window.nostr.getPublicKey(): string // returns a public key as hex +async window.nostr.signEvent(event: { created_at: number, kind: number, tags: string[][], content: string }): Event // takes an event object, adds `id`, `pubkey` and `sig` and returns it +``` + +Aside from these two basic above, the following functions can also be implemented optionally: +``` +async window.nostr.nip04.encrypt(pubkey, plaintext): string // returns ciphertext and iv as specified in nip-04 (deprecated) +async window.nostr.nip04.decrypt(pubkey, ciphertext): string // takes ciphertext and iv as specified in nip-04 (deprecated) +async window.nostr.nip44.encrypt(pubkey, plaintext): string // returns ciphertext as specified in nip-44 +async window.nostr.nip44.decrypt(pubkey, ciphertext): string // takes ciphertext as specified in nip-44 +``` + +### Recommendation to Extension Authors +To make sure that the `window.nostr` is available to nostr clients on page load, the authors who create Chromium and Firefox extensions should load their scripts by specifying `"run_at": "document_end"` in the extension's manifest. + + +### Implementation + +See https://github.com/aljazceru/awesome-nostr#nip-07-browser-extensions. \ No newline at end of file diff --git a/.github/prompts/nostr-tools.prompt.md b/.github/prompts/nostr-tools.prompt.md new file mode 100644 index 0000000..4506a8c --- /dev/null +++ b/.github/prompts/nostr-tools.prompt.md @@ -0,0 +1,361 @@ +# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools + +Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients. + +Only depends on _@scure_ and _@noble_ packages. + +This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system). + +## Installation + +```bash +# npm +npm install --save nostr-tools + +# jsr +npx jsr add @nostr/tools +``` + +If using TypeScript, this package requires TypeScript >= 5.0. + +## Documentation + +https://jsr.io/@nostr/tools/doc + +## Usage + +### Generating a private key and a public key + +```js +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure' + +let sk = generateSecretKey() // `sk` is a Uint8Array +let pk = getPublicKey(sk) // `pk` is a hex string +``` + +To get the secret key in hex format, use + +```js +import { bytesToHex, hexToBytes } from '@noble/hashes/utils' // already an installed dependency + +let skHex = bytesToHex(sk) +let backToBytes = hexToBytes(skHex) +``` + +### Creating, signing and verifying events + +```js +import { finalizeEvent, verifyEvent } from 'nostr-tools/pure' + +let event = finalizeEvent({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'hello', +}, sk) + +let isGood = verifyEvent(event) +``` + +### Interacting with one or multiple relays + +Doesn't matter what you do, you always should be using a `SimplePool`: + +```js +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure' +import { SimplePool } from 'nostr-tools/pool' + +const pool = new SimplePool() + +// let's query for an event that exists +const event = relay.get( + ['wss://relay.example.com'], + { + ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'], + }, +) +if (event) { + console.log('it exists indeed on this relay:', event) +} + +// let's publish a new event while simultaneously monitoring the relay for it +let sk = generateSecretKey() +let pk = getPublicKey(sk) + +pool.subscribe( + ['wss://a.com', 'wss://b.com', 'wss://c.com'], + { + kinds: [1], + authors: [pk], + }, + { + onevent(event) { + console.log('got event:', event) + } + } +) + +let eventTemplate = { + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'hello world', +} + +// this assigns the pubkey, calculates the event id and signs the event in a single step +const signedEvent = finalizeEvent(eventTemplate, sk) +await pool.publish(['wss://a.com', 'wss://b.com'], signedEvent) + +relay.close() +``` + +To use this on Node.js you first must install `ws` and call something like this: + +```js +import { useWebSocketImplementation } from 'nostr-tools/pool' +// or import { useWebSocketImplementation } from 'nostr-tools/relay' if you're using the Relay directly + +import WebSocket from 'ws' +useWebSocketImplementation(WebSocket) +``` + +### Parsing references (mentions) from a content based on NIP-27 + +```js +import * as nip27 from '@nostr/tools/nip27' + +for (let block of nip27.parse(evt.content)) { + switch (block.type) { + case 'text': + console.log(block.text) + break + case 'reference': { + if ('id' in block.pointer) { + console.log("it's a nevent1 uri", block.pointer) + } else if ('identifier' in block.pointer) { + console.log("it's a naddr1 uri", block.pointer) + } else { + console.log("it's an npub1 or nprofile1 uri", block.pointer) + } + break + } + case 'url': { + console.log("it's a normal url:", block.url) + break + } + case 'image': + case 'video': + case 'audio': + console.log("it's a media url:", block.url) + case 'relay': + console.log("it's a websocket url, probably a relay address:", block.url) + default: + break + } +} +``` + +### Connecting to a bunker using NIP-46 + +```js +import { generateSecretKey, getPublicKey } from '@nostr/tools/pure' +import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46' +import { SimplePool } from '@nostr/tools/pool' + +// the client needs a local secret key (which is generally persisted) for communicating with the bunker +const localSecretKey = generateSecretKey() + +// parse a bunker URI +const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com') +if (!bunkerPointer) { + throw new Error('Invalid bunker input') +} + +// create the bunker instance +const pool = new SimplePool() +const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool }) +await bunker.connect() + +// and use it +const pubkey = await bunker.getPublicKey() +const event = await bunker.signEvent({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'Hello from bunker!' +}) + +// cleanup +await signer.close() +pool.close([]) +``` + +### Parsing thread from any note based on NIP-10 + +```js +import * as nip10 from '@nostr/tools/nip10' + +// event is a nostr event with tags +const refs = nip10.parse(event) + +// get the root event of the thread +if (refs.root) { + console.log('root event:', refs.root.id) + console.log('root event relay hints:', refs.root.relays) + console.log('root event author:', refs.root.author) +} + +// get the immediate parent being replied to +if (refs.reply) { + console.log('reply to:', refs.reply.id) + console.log('reply relay hints:', refs.reply.relays) + console.log('reply author:', refs.reply.author) +} + +// get any mentioned events +for (let mention of refs.mentions) { + console.log('mentioned event:', mention.id) + console.log('mention relay hints:', mention.relays) + console.log('mention author:', mention.author) +} + +// get any quoted events +for (let quote of refs.quotes) { + console.log('quoted event:', quote.id) + console.log('quote relay hints:', quote.relays) +} + +// get any referenced profiles +for (let profile of refs.profiles) { + console.log('referenced profile:', profile.pubkey) + console.log('profile relay hints:', profile.relays) +} +``` + +### Querying profile data from a NIP-05 address + +```js +import { queryProfile } from 'nostr-tools/nip05' + +let profile = await queryProfile('jb55.com') +console.log(profile.pubkey) +// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245 +console.log(profile.relays) +// prints: [wss://relay.damus.io] +``` + +To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this: + +```js +import { useFetchImplementation } from 'nostr-tools/nip05' +useFetchImplementation(require('node-fetch')) +``` + +### Including NIP-07 types +```js +import type { WindowNostr } from 'nostr-tools/nip07' + +declare global { + interface Window { + nostr?: WindowNostr; + } +} +``` + +### Encoding and decoding NIP-19 codes + +```js +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure' +import * as nip19 from 'nostr-tools/nip19' + +let sk = generateSecretKey() +let nsec = nip19.nsecEncode(sk) +let { type, data } = nip19.decode(nsec) +assert(type === 'nsec') +assert(data === sk) + +let pk = getPublicKey(generateSecretKey()) +let npub = nip19.npubEncode(pk) +let { type, data } = nip19.decode(npub) +assert(type === 'npub') +assert(data === pk) + +let pk = getPublicKey(generateSecretKey()) +let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com'] +let nprofile = nip19.nprofileEncode({ pubkey: pk, relays }) +let { type, data } = nip19.decode(nprofile) +assert(type === 'nprofile') +assert(data.pubkey === pk) +assert(data.relays.length === 2) +``` + +### Using it with `nostr-wasm` + +[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events. + +```js +import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm' +import { initNostrWasm } from 'nostr-wasm' + +// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent +initNostrWasm().then(setNostrWasm) + +// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless', +// see https://www.npmjs.com/package/nostr-wasm for options +``` + +If you're going to use `Relay` and `SimplePool` you must also import `nostr-tools/abstract-relay` and/or `nostr-tools/abstract-pool` instead of the defaults and then instantiate them by passing the `verifyEvent`: + +```js +import { setNostrWasm, verifyEvent } from 'nostr-tools/wasm' +import { AbstractRelay } from 'nostr-tools/abstract-relay' +import { AbstractSimplePool } from 'nostr-tools/abstract-pool' +import { initNostrWasm } from 'nostr-wasm' + +initNostrWasm().then(setNostrWasm) + +const relay = AbstractRelay.connect('wss://relayable.org', { verifyEvent }) +const pool = new AbstractSimplePool({ verifyEvent }) +``` + +This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`. Benchmarks: + +``` +benchmark time (avg) (min … max) p75 p99 p995 +------------------------------------------------- ----------------------------- +• relay read message and verify event (many events) +------------------------------------------------- ----------------------------- +wasm 34.94 ms/iter (34.61 ms … 35.73 ms) 35.07 ms 35.73 ms 35.73 ms +pure js 239.7 ms/iter (235.41 ms … 243.69 ms) 240.51 ms 243.69 ms 243.69 ms +trusted 402.71 µs/iter (344.57 µs … 2.98 ms) 407.39 µs 745.62 µs 812.59 µs + +summary for relay read message and verify event + wasm + 86.77x slower than trusted + 6.86x faster than pure js +``` + +### Using from the browser (if you don't want to use a bundler) + +```html +<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script> +<script> + window.NostrTools.generateSecretKey('...') // and so on +</script> +``` + +## Plumbing + +To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available. + +## License + +This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain. + +## Contributing to this repository + +Use NIP-34 to send your patches to: + +``` +naddr1qq9kummnw3ez6ar0dak8xqg5waehxw309aex2mrp0yhxummnw3ezucn8qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejdv00jq +``` \ No newline at end of file diff --git a/.github/prompts/nostr.prompt.md b/.github/prompts/nostr.prompt.md new file mode 100644 index 0000000..b566c36 --- /dev/null +++ b/.github/prompts/nostr.prompt.md @@ -0,0 +1,44 @@ +# Nostr +## Overview + +Nostr (Notes and Other Stuff Transmitted by Relays) is an open protocol for creating decentralized, censorship-resistant social networks. It is designed to be simple, robust, and easy to implement, enabling anyone to publish and receive messages without relying on a central authority. + +## Core Concepts + +- **Events:** The basic unit of data in Nostr. Events are signed messages containing content (such as text notes), metadata, or other information. +- **Public Key Cryptography:** Every user has a public/private key pair. Events are signed with the user's private key, and followers use the public key to verify authenticity. +- **Relays:** Servers that receive, store, and forward events. Anyone can run a relay, and users can publish to or read from any relay. +- **Clients:** Applications that allow users to create, sign, and read events. Clients connect to one or more relays. + +## How It Works + +1. **User Identity:** Users are identified by their public key. There is no central user directory. +2. **Publishing:** Users sign events with their private key and send them to one or more relays. +3. **Receiving:** Clients subscribe to relays for events from specific public keys or matching certain filters. +4. **Verification:** Clients verify event signatures to ensure authenticity and integrity. +5. **Decentralization:** There is no single point of failure. Users can switch relays or use multiple relays for redundancy. + +## Protocol Specification + +- The protocol is defined in a series of documents called [NIPs (Nostr Implementation Possibilities)](https://github.com/nostr-protocol/nips). +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) describes the core event structure and relay protocol. +- Additional NIPs define extensions for features like direct messages, reactions, lists, and more. + +## Getting Started + +- Explore the [Nostr protocol repository](https://github.com/nostr-protocol/nostr) for an introduction and links to resources. +- See [NIPs](https://github.com/nostr-protocol/nips) for protocol details and extensions. +- Try out Nostr clients or run your own relay. A list of apps is available at [nostrapps.com](https://nostrapps.com/) and [nostr.net](https://nostr.net/). + +## Key Benefits + +- **Censorship Resistance:** No central authority can block users or delete content globally. +- **Interoperability:** Any client can connect to any relay, and all use the same protocol. +- **Simplicity:** The protocol is intentionally minimal, making it easy to implement in any language. + +## Further Reading + +- [Animated protocol flow](https://how-nostr-works.pages.dev/#/outbox) +- [NIP-01: Basic protocol spec](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [Nostr protocol GitHub](https://github.com/nostr-protocol/nostr) +- [NIPs repository](https://github.com/nostr-protocol/nips) diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 979003c..5d9849c 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -21,17 +21,21 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion" -import { useEffect, useRef } from "react" -import { getPublicKey, generateSecretKey, nip19 } from 'nostr-tools' +import { useEffect, useRef, useState } from "react" +import { getPublicKey, generateSecretKey, nip19, SimplePool } from 'nostr-tools' +import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46' import { InfoIcon } from "lucide-react"; import Link from "next/link"; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils' export function LoginForm() { let publicKey = useRef(null); let nsecInput = useRef<HTMLInputElement>(null); let npubInput = useRef<HTMLInputElement>(null); + let bunkerUrlInput = useRef<HTMLInputElement>(null); + const [isLoading, setIsLoading] = useState(false); + const [bunkerError, setBunkerError] = useState<string | null>(null); useEffect(() => { // handle Amber Login Response @@ -43,8 +47,74 @@ export function LoginForm() { localStorage.setItem("loginType", "amber"); window.location.href = `/profile/${amberResponse}`; } + + // Handle nostrconnect URL from bunker + if (window.location.hash && window.location.hash.startsWith('#nostrconnect://')) { + handleNostrConnect(window.location.hash.substring(1)); + } }, []); + // Handle NIP-46 connection initiated by bunker + const handleNostrConnect = async (url: string) => { + try { + setIsLoading(true); + setBunkerError(null); + + // Generate local secret key for communicating with the bunker + const localSecretKey = generateSecretKey(); + const localSecretKeyHex = bytesToHex(localSecretKey); + + // Parse the nostrconnect URL + const bunkerUrl = url.includes('://') ? url : `nostrconnect://${url}`; + const bunkerPointer = await parseBunkerInput(bunkerUrl); + + if (!bunkerPointer) { + throw new Error('Invalid bunker URL'); + } + + // Create pool and bunker signer + const pool = new SimplePool(); + const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool }); + + try { + await bunker.connect(); + + // Get the user's public key from the bunker + const userPubkey = await bunker.getPublicKey(); + + // Store connection info in localStorage + localStorage.setItem("pubkey", userPubkey); + localStorage.setItem("loginType", "bunker"); + localStorage.setItem("bunkerLocalKey", localSecretKeyHex); + localStorage.setItem("bunkerUrl", bunkerUrl); + + // Close the pool and redirect + await bunker.close(); + pool.close([]); + + window.location.href = `/profile/${nip19.npubEncode(userPubkey)}`; + } catch (err) { + console.error("Bunker connection error:", err); + setBunkerError("Failed to connect to bunker. Please check the URL and try again."); + await bunker.close().catch(console.error); + pool.close([]); + } + } catch (err) { + console.error("Bunker parsing error:", err); + setBunkerError("Invalid bunker URL format."); + } finally { + setIsLoading(false); + } + }; + + const handleBunkerLogin = async () => { + if (bunkerUrlInput.current && bunkerUrlInput.current.value) { + const bunkerUrl = bunkerUrlInput.current.value.trim(); + await handleNostrConnect(bunkerUrl); + } else { + setBunkerError("Please enter a bunker URL"); + } + }; const handleAmber = async () => { const hostname = window.location.host; @@ -140,11 +210,11 @@ export function LoginForm() { <CardHeader> <CardTitle className="text-2xl">Login to Lumina</CardTitle> <CardDescription> - Login to your account either with a nostr extension or with your nsec. + Login to your account with nostr extension, bunker, or with your nsec. </CardDescription> </CardHeader> <CardContent className="grid gap-4"> - <div className="grid grid-cols-8 gap-2"> + <div className="grid grid-cols-8 gap-2"> <Button className="w-full col-span-7" onClick={handleExtensionLogin}>Sign in with Extension (NIP-07)</Button> <Link target="_blank" href="https://www.getflamingo.org/"> <Button variant={"outline"}><InfoIcon /></Button> @@ -158,6 +228,30 @@ export function LoginForm() { </div> <hr /> or + <Accordion type="single" collapsible> + <AccordionItem value="item-1"> + <AccordionTrigger>Login with Bunker (NIP-46)</AccordionTrigger> + <AccordionContent> + <div className="grid gap-2"> + <Label htmlFor="bunkerUrl">Bunker URL</Label> + <Input placeholder="bunker://... or nostrconnect://..." + id="bunkerUrl" + ref={bunkerUrlInput} + type="text" /> + {bunkerError && <p className="text-red-500 text-sm">{bunkerError}</p>} + <Button className="w-full" + onClick={handleBunkerLogin} + disabled={isLoading}> + {isLoading ? "Connecting..." : "Sign in with Bunker"} + </Button> + <p className="text-sm text-muted-foreground"> + Use a NIP-46 compatible bunker URL that starts with bunker:// or nostrconnect:// + </p> + </div> + </AccordionContent> + </AccordionItem> + </Accordion> + or <Accordion type="single" collapsible> <AccordionItem value="item-1"> <AccordionTrigger>Login with npub (read-only)</AccordionTrigger> diff --git a/utils/bunkerUtils.ts b/utils/bunkerUtils.ts new file mode 100644 index 0000000..9c0c433 --- /dev/null +++ b/utils/bunkerUtils.ts @@ -0,0 +1,88 @@ +import { SimplePool } from 'nostr-tools'; +import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'; + +/** + * Converts a hex string to a Uint8Array. + * @param hex The hex string to convert. + * @returns Uint8Array representation of the hex string. + */ +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('Invalid hex string'); + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + } + return bytes; +} + +/** + * Creates a BunkerSigner instance from the stored bunker connection information + * @returns A Promise that resolves to a BunkerSigner instance or null if not using bunker login + */ +export async function getBunkerSigner(): Promise<{ + signer: BunkerSigner; + pool: SimplePool; +} | null> { + // Check if user is logged in with bunker + const loginType = localStorage.getItem("loginType"); + if (loginType !== "bunker") return null; + + // Get the stored bunker connection info + const localKeyHex = localStorage.getItem("bunkerLocalKey"); + const bunkerUrl = localStorage.getItem("bunkerUrl"); + + if (!localKeyHex || !bunkerUrl) return null; + + try { + // Convert hex to Uint8Array for the local key + const localKey = hexToBytes(localKeyHex); + + // Parse the bunker URL + const bunkerPointer = await parseBunkerInput(bunkerUrl); + if (!bunkerPointer) throw new Error('Invalid bunker URL'); + + // Create pool and bunker signer + const pool = new SimplePool(); + const signer = new BunkerSigner(localKey, bunkerPointer, { pool }); + + // Connect to the bunker + await signer.connect(); + + return { signer, pool }; + } catch (error) { + console.error('Error creating bunker signer:', error); + return null; + } +} + +/** + * Signs an event using the appropriate method based on login type + * @param event The unsigned event to sign + * @returns A Promise that resolves to the signed event or null if signing failed + */ +export async function signEventWithBunker(event: any): Promise<any | null> { + const loginType = localStorage.getItem("loginType"); + + if (loginType === "bunker") { + const bunkerConnection = await getBunkerSigner(); + if (!bunkerConnection) return null; + + try { + const { signer, pool } = bunkerConnection; + + // Sign the event using the bunker + const signedEvent = await signer.signEvent(event); + + // Clean up + await signer.close(); + pool.close([]); + + return signedEvent; + } catch (error) { + console.error('Error signing with bunker:', error); + return null; + } + } + + return null; +} \ No newline at end of file diff --git a/utils/utils.ts b/utils/utils.ts index 45fd701..d22ee16 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -1,5 +1,6 @@ import { Event as NostrEvent, finalizeEvent} from "nostr-tools"; import { hexToBytes } from "@noble/hashes/utils" +import { signEventWithBunker } from "./bunkerUtils"; export function getImageUrl(tags: string[][]): string { const imetaTag = tags.find(tag => tag[0] === 'imeta'); @@ -33,6 +34,15 @@ export async function signEvent(loginType: string | null, event: NostrEvent): Pr // TODO: Sign event with amber alert('Signing with Amber is not implemented yet, sorry!'); return null; + } else if (loginType === 'bunker') { + // Sign with bunker (NIP-46) + const signedWithBunker = await signEventWithBunker(event); + if (signedWithBunker) { + return signedWithBunker; + } else { + alert('Failed to sign with bunker. Please check your connection and try again.'); + return null; + } } else if (loginType === 'raw_nsec') { if (typeof window !== 'undefined') { let nsecStr = null;