-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathnostr.js
More file actions
242 lines (207 loc) · 7.22 KB
/
Copy pathnostr.js
File metadata and controls
242 lines (207 loc) · 7.22 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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
/**
* 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
* Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
* The Basic format allows git clients to authenticate via NIP-98
* @param {object} request - Fastify request object
* @returns {boolean}
*/
export function hasNostrAuth(request) {
const authHeader = request.headers.authorization;
if (!authHeader) return false;
// Direct Nostr header
if (authHeader.startsWith('Nostr ')) return true;
// Basic auth with username=nostr (for git clients)
if (authHeader.startsWith('Basic ')) {
try {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
return decoded.startsWith('nostr:');
} catch {
return false;
}
}
return false;
}
/**
* Extract token from Nostr authorization header
* Supports both "Nostr <token>" and "Basic <base64(nostr:token)>" formats
* @param {string} authHeader - Authorization header value
* @returns {string|null}
*/
export function extractNostrToken(authHeader) {
if (!authHeader) return null;
// Direct Nostr header
if (authHeader.startsWith('Nostr ')) {
return authHeader.slice(6).trim();
}
// Basic auth with username=nostr, password=token
if (authHeader.startsWith('Basic ')) {
try {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
if (decoded.startsWith('nostr:')) {
return decoded.slice(6); // Remove "nostr:" prefix to get token
}
} catch {
return null;
}
}
return null;
}
/**
* 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(/\/$/, '');
// Check for exact match first
let urlMatches = normalizedEventUrl === normalizedRequestUrl ||
normalizedEventUrl === normalizedRequestUrlNoQuery;
// For git clients: allow prefix matching (event URL is base of request URL)
// This enables git credential helpers that sign for the repo base URL
if (!urlMatches && normalizedRequestUrlNoQuery.startsWith(normalizedEventUrl + '/')) {
urlMatches = true;
}
if (!urlMatches) {
return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
}
// Validate method tag matches request method
// For git clients: allow '*' as wildcard method
const eventMethod = getTagValue(event, 'method');
if (!eventMethod) {
return { webId: null, error: 'Missing method tag in event' };
}
if (eventMethod !== '*' && 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;
}
}