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
feat: Add HTTP Schnorr signature authentication (Phase 1)
Add request-level authentication via BIP-340 Schnorr signatures
using the NIP-98 HTTP Auth scheme (Authorization: Nostr <base64>).

Verified identities resolve to did:nostr:<pubkey> URIs that work
with existing Solid ACL rules as acl:agent values — no changes
needed to the ACL layer.

Tested against all 10 official http-schnorr-auth spec test vectors
and verified live with read + write operations against a running
NSS instance.

Refs: #1845
  • Loading branch information
melvincarvalho committed Mar 1, 2026
commit 3d64b63feb48b8acd205ee3e26d9a438b1078410
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