Fix: NIP-95 upload 500 error

This commit is contained in:
Nicolas Cantu 2026-01-05 22:07:11 +01:00
parent d8311078bc
commit b5ec69624c
5 changed files with 324 additions and 7 deletions

View File

@ -0,0 +1,90 @@
# Implémentation NIP-98 pour authentification nostrcheck.me
## Date
2025-01-27
## Objectif
Implémenter l'authentification NIP-98 pour permettre l'upload de médias vers nostrcheck.me qui nécessite une authentification.
## Motivations
- nostrcheck.me retourne 401 Unauthorized sans authentification
- NIP-98 est le standard d'authentification HTTP pour Nostr
- Permettre l'utilisation de nostrcheck.me comme endpoint NIP-95
## Impacts
- **Utilisateurs** : Peuvent maintenant uploader vers nostrcheck.me s'ils sont connectés avec une extension Nostr (Alby, nos2x, etc.)
- **Fonctionnalité** : Un endpoint supplémentaire disponible pour les uploads
- **Sécurité** : L'authentification utilise la signature Nostr standard
## Modifications
### Fichiers créés
- **`lib/nip98.ts`** : Implémentation de NIP-98 pour générer des tokens d'authentification HTTP
### Fichiers modifiés
- **`lib/nip95.ts`** : Détection automatique de nostrcheck.me et génération du token NIP-98
- **`pages/api/nip95-upload.ts`** : Ajout du header Authorization avec le token NIP-98
### Fonctionnalités implémentées
#### Génération du token NIP-98 (`lib/nip98.ts`)
- Création d'un événement Nostr de type 27235 (NIP-98 HTTP Auth)
- Tags requis :
- `u` : URL complète de la requête
- `method` : Méthode HTTP (POST)
- `payload` : Hash SHA256 du body (optionnel, non implémenté pour l'instant)
- Signature de l'événement via `nostrRemoteSigner` (support Alby/nos2x)
- Encodage base64 de l'événement signé
#### Intégration dans le flux d'upload (`lib/nip95.ts`)
- Détection automatique si l'endpoint est nostrcheck.me
- Vérification de la disponibilité de NIP-98 (utilisateur connecté)
- Génération du token pour l'URL finale (nostrcheck.me)
- Passage du token à l'API proxy via paramètre de requête
#### Support dans l'API proxy (`pages/api/nip95-upload.ts`)
- Récupération du token depuis les paramètres de requête
- Ajout du header `Authorization: Nostr <token>` aux requêtes HTTP
- Préservation du token lors des redirections HTTP
## Modalités de déploiement
1. Les modifications sont dans le code source
2. Rebuild de l'application : `npm run build`
3. Redémarrage du service Next.js
4. Aucune migration de données nécessaire
## Modalités d'analyse
Pour vérifier que l'authentification fonctionne :
1. **Vérifier la connexion Nostr** :
- L'utilisateur doit être connecté avec Alby, nos2x ou une autre extension NIP-07
- Vérifier que `nostrRemoteSigner.isAvailable()` retourne true
2. **Tester l'upload vers nostrcheck.me** :
- Activer nostrcheck.me dans les settings
- Tenter un upload d'image/vidéo
- Vérifier que le token NIP-98 est généré (logs console)
- Vérifier que le header Authorization est présent dans la requête
3. **Vérifier les logs serveur** :
- Chercher les logs de redirection si applicable
- Vérifier que le header Authorization est transmis
- Vérifier la réponse de nostrcheck.me (devrait être 200 au lieu de 401)
4. **En cas d'erreur** :
- Vérifier que l'utilisateur est bien connecté
- Vérifier que le compte nostrcheck.me est créé avec la même clé
- Vérifier les logs pour les erreurs de signature
## Notes
- Le token NIP-98 est généré côté client (navigateur) car il nécessite la clé privée
- Le token est passé à l'API proxy via paramètre de requête (sécurisé car HTTPS)
- Le token est valide uniquement pour l'URL et la méthode spécifiées
- Le token inclut un timestamp et expire après un certain temps (selon la politique de nostrcheck.me)
- Pour l'instant, le hash du payload n'est pas calculé (peut être ajouté plus tard si nécessaire)
- Le kind 27235 est le kind standard pour NIP-98 HTTP Auth
## Références
- NIP-98 : https://github.com/nostr-protocol/nips/blob/master/98.md
- nostrcheck.me : https://nostrcheck.me/
- NIP-07 : https://github.com/nostr-protocol/nips/blob/master/07.md

View File

@ -0,0 +1,128 @@
# Authentification pour nostrcheck.me API
## Date
2025-01-27
## Problème
L'endpoint `https://nostrcheck.me/api/v1/media` retourne une erreur 401 Unauthorized lors des tentatives d'upload, indiquant qu'une authentification est requise.
## Symptômes
- Erreur 401 lors des appels à `/api/nip95-upload?endpoint=https://nostrcheck.me/api/v1/media`
- Message d'erreur : `{"result":false,"description":"Authorization header not found"}`
- Les uploads vers nostrcheck.me échouent systématiquement
## Recherche effectuée
### Informations trouvées sur nostrcheck.me
1. **Authentification du site web** :
- Support de NIP-07 (extensions de navigateur comme Alby)
- Code à usage unique envoyé via message direct Nostr lors de l'inscription
- Nécessite la création d'un compte sur nostrcheck.me
2. **Services offerts** :
- Enregistrement d'adresses Nostr (NIP-05)
- Hébergement de médias
- Galeries privées et publiques
- Relais privé
3. **API d'upload** :
- Endpoint : `https://nostrcheck.me/api/v1/media`
- Nécessite un header `Authorization`
- Format du header non documenté publiquement
### NIP-98 (HTTP Auth pour Nostr)
NIP-98 est le standard d'authentification HTTP pour Nostr. Il permet d'authentifier des requêtes HTTP en signant un événement Nostr qui contient :
- La méthode HTTP (POST)
- L'URL de la requête
- Le hash du body (optionnel)
- Un timestamp
Le header `Authorization` devrait contenir un token au format NIP-98, généralement :
```
Authorization: Nostr <base64-encoded-event>
```
## Hypothèses
1. **NIP-98 requis** : nostrcheck.me utilise probablement NIP-98 pour l'authentification de l'API
2. **Compte requis** : Un compte doit être créé sur nostrcheck.me avant d'utiliser l'API
3. **Signature d'événement** : Il faut probablement signer un événement Nostr avec la clé privée de l'utilisateur pour générer le token d'authentification
## À explorer
### 1. Documentation de l'API nostrcheck.me
- Vérifier s'il existe une documentation publique de l'API
- Contacter le support de nostrcheck.me pour obtenir les spécifications
- Examiner le code source si disponible
### 2. Implémentation de NIP-98
Si NIP-98 est requis, il faut :
- Créer une fonction pour générer un événement d'authentification NIP-98
- Signer cet événement avec la clé privée de l'utilisateur (via Alby ou clé locale)
- Encoder l'événement signé en base64
- Ajouter le header `Authorization: Nostr <token>` aux requêtes
### 3. Format de l'événement NIP-98
L'événement devrait contenir :
- `kind`: Probablement un kind spécifique pour l'authentification HTTP
- `tags`: Tags avec la méthode HTTP, l'URL, etc.
- `content`: Vide ou contenant des métadonnées
- `created_at`: Timestamp actuel
- `pubkey`: Clé publique de l'utilisateur
- `sig`: Signature de l'événement
### 4. Intégration dans l'API proxy
Modifier `pages/api/nip95-upload.ts` pour :
- Détecter si l'endpoint est nostrcheck.me
- Générer un token NIP-98 si l'utilisateur est authentifié
- Ajouter le header Authorization à la requête
## Code existant disponible
Le projet dispose déjà de :
- `lib/nostrRemoteSigner.ts` : Service de signature d'événements Nostr
- `lib/nostrAuth.ts` : Service d'authentification Nostr
- Support de NIP-07 via Alby extension
- Gestion des clés privées (stockage chiffré)
## Implémentation réalisée
**NIP-98 implémenté** (2025-01-27)
1. **Création de `lib/nip98.ts`** :
- Fonction `generateNip98Token()` pour générer le token d'authentification
- Support de la signature via `nostrRemoteSigner` (Alby/nos2x)
- Encodage base64 de l'événement signé
2. **Intégration dans `lib/nip95.ts`** :
- Détection automatique de nostrcheck.me
- Génération du token NIP-98 avant l'upload
- Passage du token à l'API proxy
3. **Support dans `pages/api/nip95-upload.ts`** :
- Récupération du token depuis les paramètres de requête
- Ajout du header `Authorization: Nostr <token>`
- Préservation du token lors des redirections
## Utilisation
Pour utiliser nostrcheck.me :
1. Créer un compte sur nostrcheck.me avec la même clé Nostr (via nos2x, Alby, etc.)
2. Se connecter à l'application avec la même extension/clé
3. Activer nostrcheck.me dans les settings (`/settings`)
4. L'upload vers nostrcheck.me fonctionnera automatiquement avec l'authentification NIP-98
## Notes
- L'authentification NIP-98 nécessite que l'utilisateur soit connecté avec Alby ou ait une clé privée disponible
- Le token doit être généré côté client (navigateur) car il nécessite la clé privée
- L'API proxy côté serveur ne peut pas générer le token sans avoir accès à la clé privée de l'utilisateur
- Solution possible : Générer le token côté client et le passer en paramètre à l'API proxy
## Références
- nostrcheck.me : https://nostrcheck.me/
- NIP-07 : https://github.com/nostr-protocol/nips/blob/master/07.md
- NIP-98 : À rechercher dans le repository des NIPs

View File

@ -1,5 +1,6 @@
import type { MediaRef } from '@/types/nostr' import type { MediaRef } from '@/types/nostr'
import { getEnabledNip95Apis } from './config' import { getEnabledNip95Apis } from './config'
import { generateNip98Token, isNip98Available } from './nip98'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_VIDEO_BYTES = 45 * 1024 * 1024 const MAX_VIDEO_BYTES = 45 * 1024 * 1024
@ -124,9 +125,35 @@ export async function uploadNip95Media(file: File): Promise<MediaRef> {
let lastError: Error | null = null let lastError: Error | null = null
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
try { try {
// Check if endpoint requires NIP-98 authentication (nostrcheck.me)
const needsAuth = endpoint.includes('nostrcheck.me')
let authToken: string | undefined
if (needsAuth) {
if (!isNip98Available()) {
console.warn('NIP-98 authentication required for nostrcheck.me but not available. Skipping endpoint.')
continue
}
try {
// Generate NIP-98 token for the actual endpoint (not the proxy)
// The token must be for the final destination URL
authToken = await generateNip98Token('POST', endpoint)
} catch (authError) {
console.error('Failed to generate NIP-98 token:', authError)
// Continue to next endpoint if auth fails
continue
}
}
// Always use proxy to avoid CORS, 405, and name resolution issues // Always use proxy to avoid CORS, 405, and name resolution issues
// Pass endpoint as query parameter to proxy // Pass endpoint and auth token as query parameters to proxy
const proxyUrl = `/api/nip95-upload?endpoint=${encodeURIComponent(endpoint)}` const proxyUrlParams = new URLSearchParams({
endpoint: endpoint,
})
if (authToken) {
proxyUrlParams.set('auth', authToken)
}
const proxyUrl = `/api/nip95-upload?${proxyUrlParams.toString()}`
const url = await tryUploadEndpoint(proxyUrl, formData, true) const url = await tryUploadEndpoint(proxyUrl, formData, true)
return { url, type: mediaType } return { url, type: mediaType }
} catch (e) { } catch (e) {

65
lib/nip98.ts Normal file
View File

@ -0,0 +1,65 @@
/**
* NIP-98 HTTP Auth implementation
* Generates authentication tokens for HTTP requests using Nostr events
* See: https://github.com/nostr-protocol/nips/blob/master/98.md
*/
import type { EventTemplate } from 'nostr-tools'
import { nostrRemoteSigner } from './nostrRemoteSigner'
import { nostrService } from './nostr'
/**
* Generate NIP-98 authentication token for HTTP request
* @param method HTTP method (GET, POST, etc.)
* @param url Full URL of the request
* @param payloadHash Optional SHA256 hash of the request body (for POST/PUT)
* @returns Base64-encoded signed event token
*/
export async function generateNip98Token(method: string, url: string, payloadHash?: string): Promise<string> {
const pubkey = nostrService.getPublicKey()
if (!pubkey) {
throw new Error('Public key required for NIP-98 authentication. Please connect with a Nostr extension.')
}
// Parse URL to get components
const urlObj = new URL(url)
const path = urlObj.pathname + urlObj.search
// Build event template for NIP-98
const tags: string[][] = [
['u', urlObj.origin + path],
['method', method],
]
// Add payload hash if provided (for POST/PUT requests)
if (payloadHash) {
tags.push(['payload', payloadHash])
}
const eventTemplate: EventTemplate = {
kind: 27235, // NIP-98 kind for HTTP auth
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: '',
}
// Sign the event
const signedEvent = await nostrRemoteSigner.signEvent(eventTemplate)
if (!signedEvent) {
throw new Error('Failed to sign NIP-98 authentication event')
}
// Encode event as base64 JSON
const eventJson = JSON.stringify(signedEvent)
const eventBytes = new TextEncoder().encode(eventJson)
const base64Token = btoa(String.fromCharCode(...eventBytes))
return base64Token
}
/**
* Check if NIP-98 authentication is available
*/
export function isNip98Available(): boolean {
return nostrRemoteSigner.isAvailable()
}

View File

@ -24,8 +24,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(405).json({ error: 'Method not allowed' }) return res.status(405).json({ error: 'Method not allowed' })
} }
// Get target endpoint from query or use default // Get target endpoint and auth token from query
const targetEndpoint = (req.query.endpoint as string) || 'https://void.cat/upload' const targetEndpoint = (req.query.endpoint as string) || 'https://void.cat/upload'
const authToken = req.query.auth as string | undefined
try { try {
// Parse multipart form data // Parse multipart form data
@ -63,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let response: { statusCode: number; statusMessage: string; body: string } let response: { statusCode: number; statusMessage: string; body: string }
try { try {
response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => { response = await new Promise<{ statusCode: number; statusMessage: string; body: string }>((resolve, reject) => {
function makeRequest(url: URL, redirectCount: number, fileField: FormidableFile): void { function makeRequest(url: URL, redirectCount: number, fileField: FormidableFile, authToken?: string): void {
if (redirectCount > MAX_REDIRECTS) { if (redirectCount > MAX_REDIRECTS) {
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`)) reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`))
return return
@ -80,6 +81,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isHttps = url.protocol === 'https:' const isHttps = url.protocol === 'https:'
const clientModule = isHttps ? https : http const clientModule = isHttps ? https : http
const headers = requestFormData.getHeaders() const headers = requestFormData.getHeaders()
// Add NIP-98 Authorization header if token is provided
if (authToken) {
headers['Authorization'] = `Nostr ${authToken}`
}
const requestOptions = { const requestOptions = {
hostname: url.hostname, hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80), port: url.port || (isHttps ? 443 : 80),
@ -106,8 +113,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}) })
// Drain the response before redirecting // Drain the response before redirecting
proxyResponse.resume() proxyResponse.resume()
// Make new request to redirect location // Make new request to redirect location (preserve auth token for redirects)
makeRequest(redirectUrl, redirectCount + 1, fileField) makeRequest(redirectUrl, redirectCount + 1, fileField, authToken)
return return
} catch (urlError) { } catch (urlError) {
console.error('NIP-95 proxy invalid redirect URL:', { console.error('NIP-95 proxy invalid redirect URL:', {
@ -166,7 +173,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
requestFormData.pipe(proxyRequest) requestFormData.pipe(proxyRequest)
} }
makeRequest(currentUrl, 0, fileField) makeRequest(currentUrl, 0, fileField, authToken)
}) })
} catch (requestError) { } catch (requestError) {
// Clean up temporary file before returning error // Clean up temporary file before returning error