Closes:
https://github.com/damus-io/damus/issues/1701
Signed-off-by: Daniel D’Aquino <
dan...@daquino.me>
---
README.md | 1 +
src/nip98_auth.ts | 94 ++++++++++++++++++++++++++++++++
src/notificationServiceServer.ts | 62 ++++++++++++++++++++-
3 files changed, 154 insertions(+), 3 deletions(-)
create mode 100644 src/nip98_auth.ts
diff --git a/README.md b/README.md
index 391a4ad..bd80b27 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ APNS_CERTIFICATE_FILE_PATH=./apns_cert.pem # Only if APNS_AUTH_METHOD is "c
APNS_CERTIFICATE_KEY_FILE_PATH=./apns_key.pem # Only if APNS_AUTH_METHOD is "certificate". Path to your APNS certificate key file
DB_PATH=./apns_notifications.db # Path to the SQLite database file that will be used to store data about sent notifications
RELAY_URL=ws://localhost # URL to the relay server which will be consulted to get information such as mute lists.
+API_BASE_URL=
http://localhost:8000 # Base URL of the API for NIP-98 authentication
```
6. Start strfry
diff --git a/src/nip98_auth.ts b/src/nip98_auth.ts
new file mode 100644
index 0000000..ea4ba29
--- /dev/null
+++ b/src/nip98_auth.ts
@@ -0,0 +1,94 @@
+import { Buffer } from '
https://deno.land/s...@0.88.0/node/buffer.ts';
+import crypto from 'node:crypto';
+import nostr from "npm:nostr"
+
+// TODO: Move this to nostr-js
+
+/**
+ * Validate the authorization header of a request according to NIP-98
+ * @param {string} auth_header - The authorization header of the request (`Nostr <base64_note>`)
+ * @param {string} url - The url of the request
+ * @param {string} method - The method of the request
+ * @param {string|null|undefined} body - The body of the request
+ * @returns {Promise<{authorized_pubkey: string|null, error: string|null}>} - The pubkey (hex) of the authorized user or null if not authorized, along with any error message
+ */
+export async function nip98_verify_auth_header(auth_header: string, url: string, method: string, body: string | null | undefined | Buffer | Uint8Array): Promise<{authorized_pubkey: string|null, error: string|null}> {
+ try {
+ if (!auth_header) {
+ return { authorized_pubkey: null, error: 'Nostr authorization header missing' };
+ }
+
+ const auth_header_parts = auth_header.split(' ');
+ if (auth_header_parts.length != 2) {
+ return { authorized_pubkey: null, error: 'Nostr authorization header does not have 2 parts' };
+ }
+
+ if (auth_header_parts[0] != 'Nostr') {
+ return { authorized_pubkey: null, error: 'Nostr authorization header does not start with `Nostr`' };
+ }
+
+ // Get base64 encoded note
+ const base64_encoded_note = auth_header.split(' ')[1];
+ if (!base64_encoded_note) {
+ return { authorized_pubkey: null, error: 'Nostr authorization header does not have a base64 encoded note' };
+ }
+
+ let note = JSON.parse(Buffer.from(base64_encoded_note, 'base64').toString('utf-8'));
+ if (!note) {
+ return { authorized_pubkey: null, error: 'Could not parse base64 encoded JSON note' };
+ }
+
+ if (note.kind != 27235) {
+ return { authorized_pubkey: null, error: 'Auth note kind is not 27235' };
+ }
+
+ let authorized_url = note.tags.find((tag: string[]) => tag[0] == 'u')[1];
+ let authorized_method = note.tags.find((tag: string[]) => tag[0] == 'method')[1];
+ if (authorized_url != url || authorized_method != method) {
+ return { authorized_pubkey: null, error: 'Auth note url and/or method does not match request. Auth note url: ' + authorized_url + '; Request url: ' + url + '; Auth note method: ' + authorized_method + '; Request method: ' + method };
+ }
+
+ if (current_time() - note.created_at > 60 || current_time() - note.created_at < 0) {
+ return { authorized_pubkey: null, error: 'Auth note is too old or too new' };
+ }
+
+ if (body !== undefined && body !== null) {
+ let authorized_content_hash = note.tags.find((tag: string[]) => tag[0] == 'payload')[1];
+
+ let body_hash = hash_sha256(body);
+ if (authorized_content_hash != body_hash) {
+ return { authorized_pubkey: null, error: 'Auth note payload hash does not match request body hash' };
+ }
+ }
+ else {
+ // If there is no body, there should be NO payload tag
+ if (note.tags.find((tag: string[]) => tag[0] == 'payload')) {
+ return { authorized_pubkey: null, error: 'Auth note has payload tag but request has no body' };
+ }
+ }
+
+ // Verify that the ID corresponds to the note contents
+ if (
note.id != await nostr.calculateId(note)) {
+ return { authorized_pubkey: null, error: 'Auth note id does not match note contents' };
+ }
+
+ // Verify the ID was signed by the alleged pubkey
+ let signature_valid = await nostr.verifyEvent(note);
+ if (!signature_valid) {
+ return { authorized_pubkey: null, error: 'Auth note signature is invalid' };
+ }
+
+ return { authorized_pubkey: note.pubkey, error: null };
+ } catch (error) {
+ return { authorized_pubkey: null, error: "Error when checking auth header: " + error.message };
+ }
+}
+
+function hash_sha256(data: string | Buffer | Uint8Array): string
+{
+ return crypto.createHash('sha256').update(data).digest().toString('hex');
+}
+
+function current_time() {
+ return Math.floor(Date.now() / 1000);
+}
diff --git a/src/notificationServiceServer.ts b/src/notificationServiceServer.ts
index c457819..d289114 100644
--- a/src/notificationServiceServer.ts
+++ b/src/notificationServiceServer.ts
@@ -1,22 +1,78 @@
import { Application, Router, Context } from "
https://deno.land/x/o...@v12.6.1/mod.ts";
import { NotificationManager } from "./NotificationManager.ts";
+import { nip98_verify_auth_header } from "./nip98_auth.ts";
+import { load } from "
https://deno.land/s...@0.205.0/dotenv/mod.ts";
+const env = await load();
+const BASE_URL = env["API_BASE_URL"];
+if (!BASE_URL) {
+ throw new Error("API_BASE_URL environment variable not set");
+}
const app = new Application();
const router = new Router();
+// Add a middleware that logs the request
+app.use(async (ctx, next) => {
+ await next();
+ let authorized_pubkey_log_string = "";
+ if (ctx.state.authorized_pubkey) {
+ authorized_pubkey_log_string = ` (authorized pubkey: ${ctx.state.authorized_pubkey})`;
+ }
+ console.log(`[${ctx.request.method}] ${ctx.request.url}${authorized_pubkey_log_string}: ${ctx.response.status}`);
+});
+
+// MARK: - NIP-98 authenticated endpoints
+
+app.use(async (ctx, next) => {
+ const authHeader = ctx.request.headers.get("Authorization");
+ if (!authHeader) {
+ ctx.response.status = 401;
+ ctx.response.body = "No authorization header provided";
+ return;
+ }
+
+ const body = await ctx.request.body({ type: "bytes" });
+ const bodyValue = await body.value;
+
+ const { authorized_pubkey, error } = await nip98_verify_auth_header(
+ authHeader,
+ BASE_URL + ctx.request.url.pathname,
+ ctx.request.method,
+ bodyValue
+ )
+ if (error) {
+ ctx.response.status = 401;
+ ctx.response.body = error;
+ return;
+ }
+
+ if (!authorized_pubkey) {
+ ctx.response.status = 401;
+ ctx.response.body = "No authorized pubkey found";
+ return;
+ }
+
+ ctx.state.authorized_pubkey = authorized_pubkey;
+
+ await next();
+});
// Define the endpoint for the client to send device tokens to
router.post("/user-info", async (ctx: Context) => {
- console.log("Received POST request to /user-info");
const body = await ctx.request.body();
const bodyValue = await body.value;
- console.log(bodyValue);
+
const { deviceToken, pubkey } = bodyValue
- console.log(`Received device token ${deviceToken} for pubkey ${pubkey}`)
+ if (pubkey !== ctx.state.authorized_pubkey) {
+ ctx.response.status = 403
+ ctx.response.body = "Pubkey does not match authorized pubkey";
+ return;
+ }
+
const notificationManager = new NotificationManager();
await notificationManager.setupDatabase()
notificationManager.saveUserDeviceInfo(pubkey, deviceToken);
base-commit: d1d0a67f05516576a227deb4b341bf4cb09ef81c
--
2.44.0