[PATCH strfry-push-notify v1 1/2] Add NIP-98 authentication to device token endpoint

2 views
Skip to first unread message

Daniel D’Aquino

unread,
May 21, 2024, 12:02:10 AMMay 21
to pat...@damus.io, Daniel D’Aquino
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


Daniel D’Aquino

unread,
May 21, 2024, 12:02:11 AMMay 21
to pat...@damus.io, Daniel D’Aquino
Signed-off-by: Daniel D’Aquino <dan...@daquino.me>
---
src/NotificationManager.ts | 6 ++++++
src/notificationServiceServer.ts | 19 +++++++++++++++++++
2 files changed, 25 insertions(+)

diff --git a/src/NotificationManager.ts b/src/NotificationManager.ts
index d9b4897..b9e19d5 100644
--- a/src/NotificationManager.ts
+++ b/src/NotificationManager.ts
@@ -249,6 +249,12 @@ export class NotificationManager {
const currentTimeUnix = getCurrentTimeUnix();
await this.db.query('INSERT OR REPLACE INTO user_info (id, pubkey, device_token, added_at) VALUES (?, ?, ?, ?)', [pubkey + ":" + deviceToken, pubkey, deviceToken, currentTimeUnix]);
}
+
+ async removeUserDeviceInfo(pubkey: Pubkey, deviceToken: string) {
+ this.throwIfDatabaseNotSetup();
+
+ await this.db.query('DELETE FROM user_info WHERE pubkey = (?) AND device_token = (?)', [pubkey, deviceToken]);
+ }
}


diff --git a/src/notificationServiceServer.ts b/src/notificationServiceServer.ts
index d289114..025c544 100644
--- a/src/notificationServiceServer.ts
+++ b/src/notificationServiceServer.ts
@@ -79,6 +79,25 @@ router.post("/user-info", async (ctx: Context) => {
ctx.response.body = "User info saved successfully";
});

+// Define the endpoint for the client to revoke device tokens
+router.post("/user-info/remove", async (ctx: Context) => {
+ const body = await ctx.request.body();
+ const bodyValue = await body.value;
+
+ const { pubkey, deviceToken } = bodyValue
+
+ 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.removeUserDeviceInfo(pubkey, deviceToken);
+ ctx.response.body = "User info removed successfully";
+});
+
app.use(router.routes());
app.use(router.allowedMethods());

--
2.44.0


William Casarin

unread,
May 23, 2024, 12:28:58 PMMay 23
to Daniel D’Aquino, pat...@damus.io
On Tue, May 21, 2024 at 04:02:04AM GMT, Daniel D’Aquino wrote:
>Signed-off-by: Daniel D’Aquino <dan...@daquino.me>
>---

LGTM!

Daniel D'Aquino

unread,
May 24, 2024, 8:05:41 PMMay 24
to William Casarin, pat...@damus.io


> On May 23, 2024, at 09:28, William Casarin <jb...@jb55.com> wrote:
>
> On Tue, May 21, 2024 at 04:02:04AM GMT, Daniel D’Aquino wrote:
>> Signed-off-by: Daniel D’Aquino <dan...@daquino.me>
>> ---
>
> LGTM!

Thank you for your review! Pushed to `main` (c71ee9533bb2f9a54202459a7e216f71b7b20c7e..c7f661f3b478652031d75798a16ab610657185aa)
> To unsubscribe from this group and stop receiving emails from it, send an email to patches+u...@damus.io.
>


Reply all
Reply to author
Forward
0 new messages