Skip to content

Phase 2: Link did:nostr to WebID profiles (owl:sameAs) #6

@melvincarvalho

Description

@melvincarvalho

Summary

Extend the Nostr authentication (Phase 1, #4) to link did:nostr identities to WebID profiles, enabling:

  1. Account Linking: Connect existing WebID accounts to Nostr keys
  2. Nostr-First Registration: Create new pods/WebIDs using only a Nostr key
  3. Identity Resolution: Authenticate with Nostr, resolve to canonical WebID
  4. Profile Enrichment: Import Nostr profile metadata (kind:0) into WebID

This creates a bridge between the Nostr ecosystem and Solid, allowing users to maintain a single cryptographic identity across both.


Background

Current State (Phase 1 ✓)

Phase 1 (#4) implemented direct did:nostr authentication:

Authorization: Nostr <token>
       ↓
Agent Identity: did:nostr:63fe6318dc58583...
       ↓
ACL Match: acl:agent <did:nostr:63fe6318dc58583...>

Limitations:

  • No WebID profile (no name, avatar, preferences)
  • Can't use WebID-specific features (solid:oidcIssuer, pim:storage)
  • Different identity format than traditional Solid apps expect
  • No discoverability via WebID lookup

Phase 2 Goal

Link did:nostr to a WebID profile:

Authorization: Nostr <token>
       ↓
Nostr Identity: did:nostr:63fe6318dc58583...
       ↓
Lookup: "Which WebID has owl:sameAs did:nostr:63fe6318..."?
       ↓
Canonical Identity: https://example.com/alice/#me
       ↓
ACL Match: acl:agent <https://example.com/alice/#me>

Specifications & Standards

Spec Purpose URL
owl:sameAs Identity equivalence in RDF https://www.w3.org/TR/owl-ref/#sameAs-def
Solid WebID Profile Profile document structure https://solid.github.io/webid-profile/
did:nostr DID method for Nostr https://nostrcg.github.io/did-nostr/
NIP-05 Nostr address verification https://nips.nostr.com/5
NIP-39 External identity claims https://nips.nostr.com/39
Nostr kind:0 Profile metadata event https://nips.nostr.com/1

Proposed Implementation

A. WebID Profile with Nostr Link

Add owl:sameAs and optional nostr:pubkey to WebID profiles:

Turtle Format:

@prefix foaf: <http://xmlns.com/foaf/0.1/>.
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
@prefix owl: <http://www.w3.org/2002/07/owl#>.
@prefix nostr: <https://w3id.org/nostr/vocab#>.

<#me>
    a foaf:Person;
    foaf:name "Alice";
    
    # Identity linking - this is the key relationship
    owl:sameAs <did:nostr:63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed>;
    
    # Optional: Raw pubkey for apps that need it
    nostr:pubkey "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed";
    
    # Optional: Nostr relays for discovery
    nostr:relay "wss://relay.damus.io", "wss://nos.lol";
    
    # Standard Solid profile properties
    solid:oidcIssuer <https://example.com/>;
    solid:storage <https://example.com/alice/>.

JSON-LD Format:

{
  "@context": {
    "foaf": "http://xmlns.com/foaf/0.1/",
    "solid": "http://www.w3.org/ns/solid/terms#",
    "owl": "http://www.w3.org/2002/07/owl#",
    "nostr": "https://w3id.org/nostr/vocab#"
  },
  "@id": "#me",
  "@type": "foaf:Person",
  "foaf:name": "Alice",
  "owl:sameAs": {
    "@id": "did:nostr:63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
  },
  "nostr:pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
  "solid:oidcIssuer": { "@id": "https://example.com/" },
  "solid:storage": { "@id": "https://example.com/alice/" }
}

B. Identity Resolution Flow

When a user authenticates with Nostr, resolve to their WebID:

┌─────────────────────────────────────────────────────────────────────────┐
│                    Identity Resolution Flow                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   1. Nostr Authentication                                                │
│      Authorization: Nostr <token>                                        │
│      → Verify Schnorr signature                                          │
│      → Extract: did:nostr:abc123...                                      │
│                                                                          │
│   2. WebID Lookup (New Step)                                             │
│      → Query: SELECT ?webid WHERE {                                      │
│               ?webid owl:sameAs <did:nostr:abc123...> }                  │
│      → Check local index OR                                              │
│      → Scan account profiles                                             │
│                                                                          │
│   3. Identity Selection                                                  │
│      IF WebID found:                                                     │
│         → Use WebID as canonical identity                                │
│         → Set request.webId = "https://example.com/alice/#me"           │
│      ELSE:                                                               │
│         → Use did:nostr directly (Phase 1 behavior)                     │
│         → Set request.webId = "did:nostr:abc123..."                     │
│                                                                          │
│   4. ACL Checking                                                        │
│      → Check ACL for resolved identity                                   │
│      → Both WebID and did:nostr can be in ACLs                          │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

C. Account Linking Flow

Allow existing users to link their Nostr key:

┌─────────────────────────────────────────────────────────────────────────┐
│                      Account Linking Flow                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   1. User logs in (traditional OIDC)                                     │
│      → WebID: https://example.com/alice/#me                             │
│                                                                          │
│   2. User visits /idp/settings/nostr                                     │
│      → UI shows "Link Nostr Identity"                                    │
│      → Prompts for NIP-07 extension signing                             │
│                                                                          │
│   3. User signs challenge with Nostr key                                 │
│      → Browser extension (nos2x, Alby) signs                            │
│      → Server receives signed challenge + pubkey                        │
│                                                                          │
│   4. Server verifies and links                                           │
│      → Verify Schnorr signature                                          │
│      → Store nostrPubkey in account: { ..., nostrPubkey: "abc123" }    │
│      → Update WebID profile with owl:sameAs                             │
│                                                                          │
│   5. Future logins                                                       │
│      → User can authenticate with either:                                │
│        - Traditional OIDC (username/password)                           │
│        - Nostr NIP-98 (Schnorr signature)                               │
│      → Both resolve to same WebID                                        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

D. Nostr-First Registration Flow

Allow new users to create a pod using only their Nostr key:

┌─────────────────────────────────────────────────────────────────────────┐
│                   Nostr-First Registration Flow                          │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   1. User visits /idp/register/nostr                                     │
│      → UI: "Create Pod with Nostr"                                       │
│      → Prompts for NIP-07 signature                                      │
│                                                                          │
│   2. User signs registration challenge                                   │
│      → Challenge includes: server URL, timestamp, intent                │
│      → Browser extension signs                                           │
│                                                                          │
│   3. Server verifies signature                                           │
│      → Extract pubkey                                                    │
│      → Check if pubkey already linked to account                        │
│                                                                          │
│   4. Optional: Fetch Nostr profile (kind:0)                              │
│      → Query relays for profile metadata                                 │
│      → Extract: name, about, picture, nip05                             │
│                                                                          │
│   5. Create account and pod                                              │
│      → Generate username from npub or NIP-05                            │
│      → Create pod structure                                              │
│      → Generate WebID profile with owl:sameAs                           │
│      → Store nostrPubkey in account                                      │
│                                                                          │
│   6. Return session                                                      │
│      → User is now logged in                                             │
│      → WebID: https://example.com/npub1abc123/#me                       │
│      → Can authenticate via Nostr in future                             │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

E. Profile Enrichment from Nostr

Import profile metadata from Nostr relays:

Nostr kind:0 event:

{
  "kind": 0,
  "pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
  "content": "{\"name\":\"Alice\",\"about\":\"Solid + Nostr enthusiast\",\"picture\":\"https://example.com/alice.jpg\",\"nip05\":\"alice@example.com\"}",
  "tags": []
}

Mapped to WebID profile:

<#me>
    a foaf:Person;
    foaf:name "Alice";                           # from kind:0 name
    foaf:bio "Solid + Nostr enthusiast";         # from kind:0 about  
    foaf:img <https://example.com/alice.jpg>;    # from kind:0 picture
    nostr:nip05 "alice@example.com";             # from kind:0 nip05
    owl:sameAs <did:nostr:63fe6318...>.

Sync options:

  1. On registration: Import once when creating account
  2. On demand: User clicks "Sync from Nostr" in settings
  3. Periodic: Background job updates profiles (opt-in)

F. Bidirectional Linking (Optional)

For full interoperability, the Nostr profile can also link back to the WebID:

NIP-39: External Identity Claim

{
  "kind": 0,
  "pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
  "content": "{\"name\":\"Alice\"}",
  "tags": [
    ["i", "solid:https://example.com/alice/#me", "<proof>"]
  ]
}

This allows Nostr clients to discover the user's Solid pod, and Solid apps to verify the bidirectional claim.


Files to Create/Modify

File Change Priority
src/idp/accounts.js Add nostrPubkey field to accounts High
src/idp/nostr-link.js NEW - Nostr linking endpoints High
src/idp/nostr-register.js NEW - Nostr-first registration High
src/auth/nostr.js Add WebID resolution lookup High
src/webid/profile.js Add owl:sameAs to profile generation High
src/idp/views/nostr-link.html NEW - NIP-07 linking UI Medium
src/idp/views/nostr-register.html NEW - Nostr registration UI Medium
src/nostr/relay.js NEW - Relay query for kind:0 Medium
src/nostr/profile-sync.js NEW - Profile import logic Low

API Endpoints

Link Nostr to Existing Account

POST /idp/nostr/link

Request:

{
  "challenge": "<server-provided-challenge>",
  "signature": "<schnorr-signature>",
  "pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
}

Response:

{
  "success": true,
  "webId": "https://example.com/alice/#me",
  "didNostr": "did:nostr:63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
}

Nostr-First Registration

POST /idp/nostr/register

Request:

{
  "challenge": "<server-provided-challenge>",
  "signature": "<schnorr-signature>",
  "pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
  "preferredUsername": "alice",  // Optional
  "importProfile": true          // Optional: fetch kind:0
}

Response:

{
  "success": true,
  "webId": "https://example.com/alice/#me",
  "podUrl": "https://example.com/alice/",
  "accessToken": "<jwt-token>"
}

Get Linking Challenge

GET /idp/nostr/challenge

Response:

{
  "challenge": "nostr-link:example.com:1766931639:randomhex",
  "expiresAt": 1766931699
}

Lookup WebID by Nostr Pubkey

GET /idp/nostr/lookup/:pubkey

Response:

{
  "pubkey": "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
  "webId": "https://example.com/alice/#me",
  "linked": true
}

Database Schema Changes

Account Model Update

// Current account structure
{
  id: "uuid",
  username: "alice",
  email: "alice@example.com",
  passwordHash: "...",
  webId: "https://example.com/alice/#me",
  podName: "alice",
  createdAt: "2024-01-01T00:00:00Z"
}

// Updated with Nostr
{
  id: "uuid",
  username: "alice",
  email: "alice@example.com",
  passwordHash: "...",              // Optional if Nostr-only
  webId: "https://example.com/alice/#me",
  podName: "alice",
  createdAt: "2024-01-01T00:00:00Z",
  
  // New fields
  nostrPubkey: "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
  nostrLinkedAt: "2024-06-15T12:00:00Z",
  nostrProfileSyncedAt: "2024-06-15T12:00:00Z",  // Last kind:0 import
  authMethods: ["password", "nostr"]             // Available auth methods
}

Nostr-Pubkey Index

For fast WebID lookup by pubkey:

// Index file: data/.idp/nostr-index.json
{
  "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed": {
    "accountId": "uuid-123",
    "webId": "https://example.com/alice/#me"
  },
  "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2": {
    "accountId": "uuid-456", 
    "webId": "https://example.com/bob/#me"
  }
}

Client-Side Integration (NIP-07)

Linking UI JavaScript

// Check for NIP-07 extension
if (!window.nostr) {
  showError('Please install a Nostr extension (nos2x, Alby, etc.)');
  return;
}

// Get pubkey
const pubkey = await window.nostr.getPublicKey();
console.log('Nostr pubkey:', pubkey);

// Get challenge from server
const { challenge } = await fetch('/idp/nostr/challenge').then(r => r.json());

// Sign challenge
const signature = await window.nostr.signEvent({
  kind: 27235,
  created_at: Math.floor(Date.now() / 1000),
  tags: [
    ['u', window.location.origin + '/idp/nostr/link'],
    ['method', 'POST'],
    ['challenge', challenge]
  ],
  content: ''
});

// Submit to server
const result = await fetch('/idp/nostr/link', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    challenge,
    signature: signature.sig,
    pubkey
  })
});

if (result.ok) {
  showSuccess('Nostr identity linked!');
}

Security Considerations

Challenge-Response

Linking requires proving ownership of the Nostr key:

  1. Server generates random challenge with timestamp
  2. Challenge expires after 60 seconds
  3. User signs challenge with Nostr key
  4. Server verifies signature and timestamp
  5. Prevents replay attacks and unauthorized linking

One-to-One Mapping

  • Each Nostr pubkey can only link to ONE WebID
  • Each WebID can only link to ONE Nostr pubkey
  • Prevents identity confusion and ACL bypass

Unlinking

Users should be able to unlink their Nostr key:

POST /idp/nostr/unlink
Authorization: Bearer <session-token>

→ Removes nostrPubkey from account
→ Removes owl:sameAs from WebID profile
→ Nostr auth no longer resolves to this WebID

Passwordless Accounts

If user registers with Nostr-only (no password):

  • Must have Nostr key to authenticate
  • Should prompt to add password as backup
  • Consider recovery options (other linked accounts?)

Effort Estimate

Task Effort Priority
Account schema update 1 hour High
Nostr-pubkey index 2 hours High
WebID resolution in auth 2 hours High
Account linking API 3 hours High
Linking UI (NIP-07) 3 hours High
Nostr-first registration API 3 hours Medium
Registration UI 2 hours Medium
Profile generation update 1 hour Medium
Kind:0 profile import 4 hours Low
Relay query library 3 hours Low
Bidirectional NIP-39 2 hours Low

Total: ~26 hours


Testing Plan

Unit Tests

  1. Account linking

    • Valid signature links account
    • Invalid signature rejected
    • Duplicate pubkey rejected
    • Challenge expiration works
  2. WebID resolution

    • Linked pubkey resolves to WebID
    • Unlinked pubkey returns did:nostr
    • Resolution cached properly
  3. Profile generation

    • owl:sameAs included when linked
    • nostr:pubkey included when linked
    • Unlinked profiles unchanged

Integration Tests

  1. Full linking flow

    • Login with password
    • Link Nostr key
    • Logout
    • Login with Nostr
    • Verify same WebID
  2. Nostr-first registration

    • Register with Nostr only
    • Verify pod created
    • Verify WebID has owl:sameAs
    • Authenticate with Nostr
    • Access pod resources
  3. ACL behavior

    • ACL with WebID grants access via Nostr auth
    • ACL with did:nostr grants access (backward compat)

Migration Path

Existing Phase 1 Users

Users who created ACLs with did:nostr:pubkey:

# Old ACL (still works!)
acl:agent <did:nostr:63fe6318...>

After linking:

# New ACL (recommended)
acl:agent <https://example.com/alice/#me>

# Or both for transition period
acl:agent <https://example.com/alice/#me>;
acl:agent <did:nostr:63fe6318...>.

Backward Compatibility

  • Phase 1 behavior preserved: unlinked Nostr auth returns did:nostr
  • ACLs with did:nostr continue to work
  • No breaking changes for existing deployments

Future Enhancements (Phase 3+)

  1. Multiple Nostr Keys: Link multiple keys to one WebID (different devices)
  2. Key Rotation: Replace linked key while preserving account
  3. Social Graph Import: Import Nostr follows as foaf:knows
  4. Relay Discovery: Advertise preferred relays in WebID
  5. NIP-05 Verification: Verify domain ownership via NIP-05
  6. Cross-Server Resolution: Federated pubkey→WebID lookup

References


Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    nostrNostr relay, did:nostr auth, NIP-related

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions