Skip to content

Commit e13aeee

Browse files
Fix Nostr auth and WAC parser for Turtle ACL format
- Fix nostr.js to use own base64 decoding instead of broken nostr-tools unpackEventFromToken function - Update WAC parser to support Turtle ACL format (not just JSON-LD) - Make parseAcl async to support turtle-to-JSON-LD conversion - Update checker.js to await async parseAcl calls - Add test for Turtle ACL format parsing This enables did:nostr URIs in ACL files for access control.
1 parent e922f85 commit e13aeee

4 files changed

Lines changed: 119 additions & 48 deletions

File tree

src/auth/nostr.js

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,7 @@
1212
*/
1313

1414
import { verifyEvent } from 'nostr-tools';
15-
import {
16-
unpackEventFromToken,
17-
validateEventKind,
18-
validateEventTimestamp,
19-
validateEventUrlTag,
20-
validateEventMethodTag,
21-
validateEventPayloadTag
22-
} from 'nostr-tools/nip98';
15+
import crypto from 'crypto';
2316

2417
// NIP-98 event kind (references RFC 7235)
2518
const HTTP_AUTH_KIND = 27235;
@@ -49,6 +42,34 @@ export function extractNostrToken(authHeader) {
4942
return authHeader.slice(6).trim();
5043
}
5144

45+
/**
46+
* Decode NIP-98 event from base64 token
47+
* @param {string} token - Base64 encoded event
48+
* @returns {object|null} Decoded event or null
49+
*/
50+
function decodeEvent(token) {
51+
try {
52+
const decoded = Buffer.from(token, 'base64').toString('utf8');
53+
return JSON.parse(decoded);
54+
} catch {
55+
return null;
56+
}
57+
}
58+
59+
/**
60+
* Get tag value from event
61+
* @param {object} event - Nostr event
62+
* @param {string} tagName - Tag name (e.g., 'u', 'method')
63+
* @returns {string|null} Tag value or null
64+
*/
65+
function getTagValue(event, tagName) {
66+
if (!event.tags || !Array.isArray(event.tags)) {
67+
return null;
68+
}
69+
const tag = event.tags.find(t => Array.isArray(t) && t[0] === tagName);
70+
return tag ? tag[1] : null;
71+
}
72+
5273
/**
5374
* Convert Nostr pubkey to did:nostr URI
5475
* @param {string} pubkey - 64-char hex public key
@@ -70,24 +91,21 @@ export async function verifyNostrAuth(request) {
7091
return { webId: null, error: 'Missing Nostr token' };
7192
}
7293

73-
let event;
74-
try {
75-
event = unpackEventFromToken(token);
76-
} catch (err) {
77-
return { webId: null, error: 'Invalid token format: ' + err.message };
78-
}
79-
94+
// Decode the event
95+
const event = decodeEvent(token);
8096
if (!event) {
81-
return { webId: null, error: 'Could not decode event from token' };
97+
return { webId: null, error: 'Invalid token format: could not decode base64 JSON' };
8298
}
8399

84100
// Validate event kind (must be 27235)
85-
if (!validateEventKind(event, HTTP_AUTH_KIND)) {
101+
if (event.kind !== HTTP_AUTH_KIND) {
86102
return { webId: null, error: `Invalid event kind: expected ${HTTP_AUTH_KIND}, got ${event.kind}` };
87103
}
88104

89105
// Validate timestamp (within ±60 seconds)
90-
if (!validateEventTimestamp(event, TIMESTAMP_TOLERANCE)) {
106+
const now = Math.floor(Date.now() / 1000);
107+
const eventTime = event.created_at;
108+
if (!eventTime || Math.abs(now - eventTime) > TIMESTAMP_TOLERANCE) {
91109
return { webId: null, error: 'Event timestamp outside acceptable window (±60s)' };
92110
}
93111

@@ -97,21 +115,32 @@ export async function verifyNostrAuth(request) {
97115
const fullUrl = `${protocol}://${host}${request.url}`;
98116

99117
// Validate URL tag matches request URL
100-
if (!validateEventUrlTag(event, fullUrl)) {
101-
// Also try without query string for compatibility
102-
const urlWithoutQuery = fullUrl.split('?')[0];
103-
if (!validateEventUrlTag(event, urlWithoutQuery)) {
104-
return { webId: null, error: `URL mismatch: event URL does not match request URL` };
105-
}
118+
const eventUrl = getTagValue(event, 'u');
119+
if (!eventUrl) {
120+
return { webId: null, error: 'Missing URL tag in event' };
121+
}
122+
123+
// Compare URLs (normalize by removing trailing slashes)
124+
const normalizedEventUrl = eventUrl.replace(/\/$/, '');
125+
const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
126+
const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
127+
128+
if (normalizedEventUrl !== normalizedRequestUrl && normalizedEventUrl !== normalizedRequestUrlNoQuery) {
129+
return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
106130
}
107131

108132
// Validate method tag matches request method
109-
if (!validateEventMethodTag(event, request.method)) {
110-
return { webId: null, error: `Method mismatch: expected ${request.method}` };
133+
const eventMethod = getTagValue(event, 'method');
134+
if (!eventMethod) {
135+
return { webId: null, error: 'Missing method tag in event' };
136+
}
137+
if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
138+
return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
111139
}
112140

113141
// Validate payload hash if present and request has body
114-
if (request.body && event.tags.some(t => t[0] === 'payload')) {
142+
const payloadTag = getTagValue(event, 'payload');
143+
if (payloadTag && request.body) {
115144
let bodyString;
116145
if (typeof request.body === 'string') {
117146
bodyString = request.body;
@@ -121,11 +150,17 @@ export async function verifyNostrAuth(request) {
121150
bodyString = JSON.stringify(request.body);
122151
}
123152

124-
if (!validateEventPayloadTag(event, bodyString)) {
153+
const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
154+
if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
125155
return { webId: null, error: 'Payload hash mismatch' };
126156
}
127157
}
128158

159+
// Validate pubkey exists
160+
if (!event.pubkey || typeof event.pubkey !== 'string' || event.pubkey.length !== 64) {
161+
return { webId: null, error: 'Invalid or missing pubkey' };
162+
}
163+
129164
// Verify Schnorr signature
130165
const isValid = verifyEvent(event);
131166
if (!isValid) {
@@ -154,7 +189,7 @@ export async function getNostrPubkey(request) {
154189
}
155190

156191
try {
157-
const event = unpackEventFromToken(token);
192+
const event = decodeEvent(token);
158193
return event?.pubkey || null;
159194
} catch {
160195
return null;

src/wac/checker.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
6464
const content = await storage.read(resourceAclPath);
6565
if (content) {
6666
const aclUrl = getAclUrl(resourceUrl, isContainer);
67-
const authorizations = parseAcl(content.toString(), aclUrl);
67+
const authorizations = await parseAcl(content.toString(), aclUrl);
6868
return { authorizations, isDefault: false, targetUrl: resourceUrl };
6969
}
7070
}
@@ -80,7 +80,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
8080
const content = await storage.read(parentAclPath);
8181
if (content) {
8282
const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
83-
const authorizations = parseAcl(content.toString(), parentAclPath);
83+
const authorizations = await parseAcl(content.toString(), parentAclPath);
8484
return { authorizations, isDefault: true, targetUrl: parentUrl };
8585
}
8686
}
@@ -93,7 +93,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
9393
const content = await storage.read('/.acl');
9494
if (content) {
9595
const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
96-
const authorizations = parseAcl(content.toString(), '/.acl');
96+
const authorizations = await parseAcl(content.toString(), '/.acl');
9797
return { authorizations, isDefault: true, targetUrl: rootUrl };
9898
}
9999
}

src/wac/parser.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
22
* WAC (Web Access Control) Parser
3-
* Parses JSON-LD .acl files into authorization rules
3+
* Parses ACL files (JSON-LD or Turtle) into authorization rules
44
*/
55

6+
import { turtleToJsonLd } from '../rdf/turtle.js';
7+
68
const ACL = 'http://www.w3.org/ns/auth/acl#';
79
const FOAF = 'http://xmlns.com/foaf/0.1/';
810

@@ -21,16 +23,31 @@ export const AgentClass = {
2123
};
2224

2325
/**
24-
* Parse a JSON-LD ACL document
25-
* @param {string|object} content - JSON-LD content (string or parsed object)
26+
* Parse an ACL document (JSON-LD or Turtle)
27+
* @param {string|object} content - ACL content (JSON-LD string/object or Turtle string)
2628
* @param {string} aclUrl - URL of the ACL document
27-
* @returns {Array<Authorization>} List of authorization rules
29+
* @returns {Promise<Array<Authorization>>} List of authorization rules
2830
*/
29-
export function parseAcl(content, aclUrl) {
31+
export async function parseAcl(content, aclUrl) {
3032
let doc;
31-
try {
32-
doc = typeof content === 'string' ? JSON.parse(content) : content;
33-
} catch {
33+
34+
// If already an object, use it directly
35+
if (typeof content === 'object' && content !== null) {
36+
doc = content;
37+
} else if (typeof content === 'string') {
38+
// Try JSON-LD first
39+
try {
40+
doc = JSON.parse(content);
41+
} catch {
42+
// Not JSON, try Turtle
43+
try {
44+
doc = await turtleToJsonLd(content, aclUrl);
45+
} catch (turtleError) {
46+
// Neither JSON-LD nor valid Turtle
47+
return [];
48+
}
49+
}
50+
} else {
3451
return [];
3552
}
3653

test/wac.test.js

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { checkAccess, getRequiredMode } from '../src/wac/checker.js';
1818

1919
describe('WAC Parser', () => {
2020
describe('parseAcl', () => {
21-
it('should parse a simple ACL', () => {
21+
it('should parse a simple ACL', async () => {
2222
const acl = {
2323
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
2424
'@graph': [{
@@ -30,15 +30,15 @@ describe('WAC Parser', () => {
3030
}]
3131
};
3232

33-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
33+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
3434

3535
assert.strictEqual(auths.length, 1);
3636
assert.ok(auths[0].agents.includes('https://alice.example/#me'));
3737
assert.ok(auths[0].modes.includes(AccessMode.READ));
3838
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
3939
});
4040

41-
it('should parse public access', () => {
41+
it('should parse public access', async () => {
4242
const acl = {
4343
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },
4444
'@graph': [{
@@ -50,14 +50,14 @@ describe('WAC Parser', () => {
5050
}]
5151
};
5252

53-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
53+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
5454

5555
assert.strictEqual(auths.length, 1);
5656
assert.ok(auths[0].agentClasses.includes('foaf:Agent'));
5757
assert.ok(auths[0].modes.includes(AccessMode.READ));
5858
});
5959

60-
it('should parse default authorizations for containers', () => {
60+
it('should parse default authorizations for containers', async () => {
6161
const acl = {
6262
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
6363
'@graph': [{
@@ -69,16 +69,35 @@ describe('WAC Parser', () => {
6969
}]
7070
};
7171

72-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
72+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
7373

7474
assert.strictEqual(auths.length, 1);
7575
assert.ok(auths[0].default.includes('https://alice.example/folder/'));
7676
});
7777

78-
it('should handle invalid JSON gracefully', () => {
79-
const auths = parseAcl('not valid json', 'https://example.com/.acl');
78+
it('should handle invalid JSON gracefully', async () => {
79+
const auths = await parseAcl('not valid json', 'https://example.com/.acl');
8080
assert.strictEqual(auths.length, 0);
8181
});
82+
83+
it('should parse Turtle ACL format', async () => {
84+
const turtleAcl = `
85+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
86+
87+
<#owner>
88+
a acl:Authorization;
89+
acl:agent <did:nostr:abc123>;
90+
acl:accessTo <https://example.com/resource>;
91+
acl:mode acl:Read, acl:Write.
92+
`;
93+
94+
const auths = await parseAcl(turtleAcl, 'https://example.com/.acl');
95+
96+
assert.strictEqual(auths.length, 1);
97+
assert.ok(auths[0].agents.includes('did:nostr:abc123'));
98+
assert.ok(auths[0].modes.includes(AccessMode.READ));
99+
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
100+
});
82101
});
83102

84103
describe('generateOwnerAcl', () => {

0 commit comments

Comments
 (0)