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

Nip98 #92

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
163 changes: 163 additions & 0 deletions app/api/nip98/nip98_client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// <script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>

const { signEvent } = NostrTools;

var loggedIn = false;
let pubkey = "";
let username = "";
let avatarURL = "https://cdn-icons-png.flaticon.com/512/149/149071.png";

function loadUser() {
if (window.nostr) {
window.nostr.getPublicKey().then(function (pubkey) {
if (pubkey) {
loggedIn = true
console.log("fetched pubkey", pubkey)
}
}).catch((err) => {
console.log("LoadUser Err", err);
console.log("logoff section")
loggedIn = false
});
}
}


class NostrAuthClient {
/**
* Construct a new NostrAuthClient instance.
* @param {string} pubkey - Nostr public key of the user.
*/
constructor(pubkey) {
this.publicKey = pubkey;
}

// Generate a Nostr event for HTTP authentication
async createAuthEvent(url, method, payload = null) {
const tags = [
['u', url],
['method', method.toUpperCase()]
];

// If payload exists, add its SHA256 hash
if (payload) {
const payloadHash = await this.sha256(payload);
tags.push(['payload', payloadHash]);
}

const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: '',
pubkey: this.publicKey
};
console.log('event: ', event)

// Calculate event ID
event.id = await this.calculateId(event);

// Sign the event
event.sig = await window.nostr.signEvent(event);
return event;
}

// Utility functions for cryptographic operations
async sha256(message) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

async calculateId(event) {
const eventData = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
return await this.sha256(eventData);
}
}

// Make an authenticated request
async function fetchWithNostrAuth(url, options = {}) {
const method = options.method || 'GET';
const payload = options.body || null;

const client = new NostrAuthClient(pubkey);
const authEvent = await client.createAuthEvent(url, method, payload);

// Convert event to base64
const authHeader = 'Nostr ' + btoa(JSON.stringify(authEvent));

// Add auth header to request
const headers = new Headers(options.headers || {});
headers.set('Authorization', authHeader);

// Make the request
return fetch(url, {
...options,
headers
});
}

// Helper function to get base domain/host with port if needed
function getBaseUrl() {
// Get the full host (includes port if it exists)
const host = window.location.host;
// Get the protocol (http: or https:)
const protocol = window.location.protocol;
// Combine them
return `${protocol}//${host}`;
}

async function authNIP98() {

const roomName = "TestRoom";
const preferredRelays = ['wss://hivetalk.nostr1.com']
const isModerator = true;

try {
const baseUrl = getBaseUrl();
fetchWithNostrAuth(`${baseUrl}/api/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
room: roomName,
username: username,
avatarURL: avatarURL,
relays: preferredRelays,
isPresenter: isModerator,
}),
}).then(response => {
console.log('response', response.status)
if (response.status === 302) {
console.log("response status is 302") // Get the redirect URL from the response
const data = response.json();
window.location.href = data.redirectUrl;
} else if (response.ok) {
console.log("response.ok", response.ok)
return response.json();
} else {
console.error('Login failed');
}
}).then(data => {
console.log('auth success: ', data);
document.getElementById('protected').innerHTML = data['message'];
})

} catch (error) {
console.error('Error:', error);
document.getElementById('protected').innerHTML = error;
}
}


loadUser();
authNIP98();
178 changes: 178 additions & 0 deletions app/api/nip98/nip98_server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const express = require('express')
const cors = require('cors')
const { verifyEvent } = require('nostr-tools');

class NostrAuthMiddleware {
constructor(options = {}) {
this.timeWindow = options.timeWindow || 60; // seconds
}

// Middleware function for Express
middleware() {
return async (req, res, next) => {
try {
const isValid = await this.validateRequest(req);
console.log("isValid : ", isValid)
if (!isValid) {
return res.status(401).json({
error: 'Invalid Nostr authentication'
});
}
next();
} catch (error) {
console.error('Nostr auth error:', error);
res.status(401).json({
error: 'Authentication failed'
});
}
};
}

async validateRequest(req) {
// Extract the Nostr event from Authorization header
const authHeader = req.headers.authorization;
console.log("validate request auth header: ", authHeader)

if (!authHeader?.startsWith('Nostr ')) {
return false;
}

try {
// Decode the base64 event
const eventStr = Buffer.from(authHeader.slice(6), 'base64').toString();
const event = JSON.parse(eventStr);

console.log("eventStr: ", eventStr)
console.log('event decoded: ', event)

// Validate the event
return await this.validateEvent(event.sig, req);
} catch (error) {
console.error('Error parsing auth event:', error);
return false;
}
}

async validateEvent(event, req) {
// 1. Check kind
if (event.kind !== 27235) {
return false;
}
console.log("check kind")

// 2. Check timestamp
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - event.created_at) > this.timeWindow) {
return false;
}
console.log("check timestamp")

// 3. Check URL
const urlTag = event.tags.find(tag => tag[0] === 'u');

console.log('urltag: ', urlTag[1])
console.log('full url: ', this.getFullUrl(req))

if (!urlTag || urlTag[1] !== this.getFullUrl(req)) {
return false;
}
console.log("check URL")

// 4. Check method
const methodTag = event.tags.find(tag => tag[0] === 'method');
if (!methodTag || methodTag[1] !== req.method) {
return false;
}
console.log("check method")

// 5. Check payload hash if present
if (req.body && Object.keys(req.body).length > 0) {
const payloadTag = event.tags.find(tag => tag[0] === 'payload');
if (payloadTag) {
const bodyHash = await this.sha256(JSON.stringify(req.body));
if (bodyHash !== payloadTag[1]) {
return false;
}
}
}
console.log("check payload hash if present")

// 6. Verify event signature
return await this.verifySignature(event);
}

// Utility functions
getFullUrl(req) {
// return `${req.protocol}://${req.get('host')}${req.originalUrl}`;
return `https://${req.get('host')}${req.originalUrl}`;
}

async sha256(message) {
return crypto
.createHash('sha256')
.update(message)
.digest('hex');
}

async calculateEventId(event) {
// Serialize the event data according to NIP-01
const serialized = JSON.stringify([
0, // Reserved for future use
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);

// Calculate SHA256 hash
return await this.sha256(serialized);
}

async verifySignature(event) {
let isGood = verifyEvent(event)
console.log("Verify Event", isGood)
return isGood
}

}

const app = express();
const nostrAuth = new NostrAuthMiddleware();
app.use(express.json({ limit: '50mb' })); // Increase the limit if necessary

app.use(
cors({
origin: '*',
})
)

app.get('/api', (req, res) => {
res.setHeader('Content-Type', 'text/html')
res.setHeader('Cache-Control', 's-max-age=1, stale-while-revalidate')
res.send('hello world')
})

app.post('/api/auth',
nostrAuth.middleware(),
(req, res) => {
try {
// Accessing the JSON body sent by the client
const { room, username, avatarURL, relays, isPresenter } = req.body;
console.log('Room:', room);
console.log('Username:', username);
console.log('Avatar URL:', avatarURL);
console.log('Relays:', relays);
console.log('isPresenter', isPresenter);

// TODO: Redirect to hivetalk room give above info, correctly
res.status(200).json({ message: 'Authentication successful'});

} catch (error) {
console.log("authentication failed")
res.status(401).json({ error: 'Authentication failed' });
}
}
);

module.exports = app;
Loading