-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathnostr.js
More file actions
197 lines (170 loc) · 5.87 KB
/
Copy pathnostr.js
File metadata and controls
197 lines (170 loc) · 5.87 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
/**
* Nostr NIP-98 Authentication
*
* Implements HTTP authentication using Schnorr signatures as defined in:
* - NIP-98: https://nips.nostr.com/98
* - JIP-0001: https://github.com/JavaScriptSolidServer/jips/blob/main/jip-0001.md
*
* Authorization header format: "Nostr <base64-encoded-event>"
*
* The authenticated identity is returned as a did:nostr URI:
* did:nostr:<64-char-hex-pubkey>
*/
import { verifyEvent } from 'nostr-tools';
import crypto from 'crypto';
// NIP-98 event kind (references RFC 7235)
const HTTP_AUTH_KIND = 27235;
// Timestamp tolerance in seconds
const TIMESTAMP_TOLERANCE = 60;
/**
* Check if request has Nostr authentication
* @param {object} request - Fastify request object
* @returns {boolean}
*/
export function hasNostrAuth(request) {
const authHeader = request.headers.authorization;
return authHeader && authHeader.startsWith('Nostr ');
}
/**
* Extract token from Nostr authorization header
* @param {string} authHeader - Authorization header value
* @returns {string|null}
*/
export function extractNostrToken(authHeader) {
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return null;
}
return authHeader.slice(6).trim();
}
/**
* Decode NIP-98 event from base64 token
* @param {string} token - Base64 encoded event
* @returns {object|null} Decoded event or null
*/
function decodeEvent(token) {
try {
const decoded = Buffer.from(token, 'base64').toString('utf8');
return JSON.parse(decoded);
} catch {
return null;
}
}
/**
* Get tag value from event
* @param {object} event - Nostr event
* @param {string} tagName - Tag name (e.g., 'u', 'method')
* @returns {string|null} Tag value or null
*/
function getTagValue(event, tagName) {
if (!event.tags || !Array.isArray(event.tags)) {
return null;
}
const tag = event.tags.find(t => Array.isArray(t) && t[0] === tagName);
return tag ? tag[1] : null;
}
/**
* Convert Nostr pubkey to did:nostr URI
* @param {string} pubkey - 64-char hex public key
* @returns {string} did:nostr URI
*/
export function pubkeyToDidNostr(pubkey) {
return `did:nostr:${pubkey.toLowerCase()}`;
}
/**
* Verify NIP-98 authentication and return agent identity
* @param {object} request - Fastify request object
* @returns {Promise<{webId: string|null, error: string|null}>}
*/
export async function verifyNostrAuth(request) {
const token = extractNostrToken(request.headers.authorization);
if (!token) {
return { webId: null, error: 'Missing Nostr token' };
}
// Decode the event
const event = decodeEvent(token);
if (!event) {
return { webId: null, error: 'Invalid token format: could not decode base64 JSON' };
}
// Validate event kind (must be 27235)
if (event.kind !== HTTP_AUTH_KIND) {
return { webId: null, error: `Invalid event kind: expected ${HTTP_AUTH_KIND}, got ${event.kind}` };
}
// Validate timestamp (within ±60 seconds)
const now = Math.floor(Date.now() / 1000);
const eventTime = event.created_at;
if (!eventTime || Math.abs(now - eventTime) > TIMESTAMP_TOLERANCE) {
return { webId: null, error: 'Event timestamp outside acceptable window (±60s)' };
}
// Build full URL for validation
const protocol = request.protocol || 'http';
const host = request.headers.host || request.hostname;
const fullUrl = `${protocol}://${host}${request.url}`;
// Validate URL tag matches request URL
const eventUrl = getTagValue(event, 'u');
if (!eventUrl) {
return { webId: null, error: 'Missing URL tag in event' };
}
// Compare URLs (normalize by removing trailing slashes)
const normalizedEventUrl = eventUrl.replace(/\/$/, '');
const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
if (normalizedEventUrl !== normalizedRequestUrl && normalizedEventUrl !== normalizedRequestUrlNoQuery) {
return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
}
// Validate method tag matches request method
const eventMethod = getTagValue(event, 'method');
if (!eventMethod) {
return { webId: null, error: 'Missing method tag in event' };
}
if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
}
// Validate payload hash if present and request has body
const payloadTag = getTagValue(event, 'payload');
if (payloadTag && request.body) {
let bodyString;
if (typeof request.body === 'string') {
bodyString = request.body;
} else if (Buffer.isBuffer(request.body)) {
bodyString = request.body.toString();
} else {
bodyString = JSON.stringify(request.body);
}
const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
return { webId: null, error: 'Payload hash mismatch' };
}
}
// Validate pubkey exists
if (!event.pubkey || typeof event.pubkey !== 'string' || event.pubkey.length !== 64) {
return { webId: null, error: 'Invalid or missing pubkey' };
}
// Verify Schnorr signature
const isValid = verifyEvent(event);
if (!isValid) {
return { webId: null, error: 'Invalid Schnorr signature' };
}
// Return did:nostr as the agent identifier
const didNostr = pubkeyToDidNostr(event.pubkey);
return { webId: didNostr, error: null };
}
/**
* Get Nostr pubkey from request if authenticated via NIP-98
* @param {object} request - Fastify request object
* @returns {Promise<string|null>} Hex pubkey or null
*/
export async function getNostrPubkey(request) {
if (!hasNostrAuth(request)) {
return null;
}
const token = extractNostrToken(request.headers.authorization);
if (!token) {
return null;
}
try {
const event = decodeEvent(token);
return event?.pubkey || null;
} catch {
return null;
}
}