Skip to content
Open
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
5 changes: 3 additions & 2 deletions lib/api/authn/index.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import oidc from './webid-oidc.mjs'
import tls from './webid-tls.mjs'
import forceUser from './force-user.mjs'
import nostr from './webid-nostr.mjs'

export { oidc, tls, forceUser }
export { oidc, tls, forceUser, nostr }

// Provide a default export so callers can `import Auth from './lib/api/authn/index.mjs'`
export default { oidc, tls, forceUser }
export default { oidc, tls, forceUser, nostr }
118 changes: 118 additions & 0 deletions lib/api/authn/webid-nostr.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use strict'

import { schnorr } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
import Debug from 'debug'

const debug = Debug('solid:authn:nostr')

/**
* Verifies a NIP-98 HTTP Auth event from the Authorization header.
*
* @see https://nostrcg.github.io/http-schnorr-auth/
* @see https://github.com/nostr-protocol/nips/blob/master/98.md
*
* @param {object} req - Express request object
* @param {object} [options] - Options for testing
* @param {number} [options.now] - Override current time (unix seconds)
* @returns {string|null} `did:nostr:<pubkey>` on success, null on failure
*/
export async function verifyNostrAuth (req, options = {}) {
const authHeader = req.get('Authorization')
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return null
}

debug('Processing Nostr auth...')

try {
const base64Event = authHeader.slice(6)
const eventJson = Buffer.from(base64Event, 'base64').toString('utf-8')
const event = JSON.parse(eventJson)

// Validate required fields
if (!event.pubkey || !event.sig || !event.id || !event.tags) {
debug('Invalid event structure')
return null
}

// Kind must be 27235 (NIP-98 HTTP Auth)
if (event.kind !== 27235) {
debug(`Wrong event kind: ${event.kind}`)
return null
}

// Timestamp within 60 seconds
const now = options.now || Math.floor(Date.now() / 1000)
if (Math.abs(now - event.created_at) > 60) {
debug('Event timestamp out of range')
return null
}

// Required tags
const urlTag = event.tags.find(t => t[0] === 'u')
if (!urlTag) {
debug('Missing URL tag')
return null
}

// Validate URL matches the request
const requestUrl = req.absoluteUrl ||
(req.protocol + '://' + req.get('host') + req.originalUrl)
if (urlTag[1] !== requestUrl) {
debug(`URL mismatch: ${urlTag[1]} vs ${requestUrl}`)
return null
}

const methodTag = event.tags.find(t => t[0] === 'method')
if (!methodTag) {
debug('Missing method tag')
return null
}

if (methodTag[1].toUpperCase() !== req.method.toUpperCase()) {
debug(`Method mismatch: ${methodTag[1]} vs ${req.method}`)
return null
}

// Verify event ID is SHA-256 of serialized event (NIP-01)
const serialized = JSON.stringify([
0, event.pubkey, event.created_at, event.kind, event.tags, event.content
])
const expectedId = bytesToHex(sha256(new TextEncoder().encode(serialized)))
if (event.id !== expectedId) {
debug('Event ID mismatch')
return null
}

// Verify BIP-340 Schnorr signature
const valid = schnorr.verify(event.sig, event.id, event.pubkey)
if (!valid) {
debug('Invalid Schnorr signature')
return null
}

// Optional: verify payload hash for requests with bodies
const payloadTag = event.tags.find(t => t[0] === 'payload')
if (payloadTag && req.body) {
const bodyBytes = typeof req.body === 'string'
? new TextEncoder().encode(req.body)
: req.body
const bodyHash = bytesToHex(sha256(bodyBytes))
if (payloadTag[1] !== bodyHash) {
debug('Payload hash mismatch')
return null
}
}

const webId = `did:nostr:${event.pubkey}`
debug(`Authenticated: ${webId}`)
return webId
} catch (err) {
debug(`Auth error: ${err.message}`)
return null
}
}

export default { verifyNostrAuth }
6 changes: 6 additions & 0 deletions lib/api/authn/webid-oidc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export function initialize (app, argv) {

// Perform the actual authentication
app.use('/', async (req, res, next) => {
// Skip OIDC auth for Nostr-authenticated requests (handled by allow.mjs)
const authHeader = req.get('Authorization')
if (authHeader && authHeader.startsWith('Nostr ')) {
return next()
}

oidc.rs.authenticate({ tokenTypesSupported: argv.tokenTypesSupported })(req, res, (err) => {
// Error handling should be deferred to the ldp in case a user with a bad token is trying
// to access a public resource
Expand Down
9 changes: 8 additions & 1 deletion lib/handlers/allow.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ACL from '../acl-checker.mjs'
import { verifyNostrAuth } from '../api/authn/webid-nostr.mjs'
// import debug from '../debug.mjs'

export default function allow (mode) {
Expand Down Expand Up @@ -38,7 +39,13 @@ export default function allow (mode) {
// Obtain and store the ACL of the requested resource
const resourceUrl = rootUrl + resourcePath
// Ensure the user has the required permission
const userId = req.session.userId
let userId = req.session?.userId
if (!userId) {
userId = await verifyNostrAuth(req)
if (userId) {
res.set('User', userId)
}
}
try {
req.acl = ACL.createFromLDPAndRequest(resourceUrl, ldp, req)

Expand Down
Loading
Loading