Skip to content

Support did:key Authentication (LWS10 Spec) #86

@melvincarvalho

Description

@melvincarvalho

Support did:key Authentication (LWS10 Spec)

🎯 Motivation

Backend services and autonomous agents need simple, secure authentication without the X.509 certificate ceremony of WebID-TLS. The W3C LWS protocol suite defines did:key authentication - a modern approach using self-issued JWTs signed with cryptographic keypairs.

Current pain point: WebID-TLS requires generating X.509 certificates, embedding public keys in WebID profiles, and configuring TLS. This is heavyweight for simple CLI tools and bot accounts.

What we want: Agent generates an ed25519 keypair, derives a did:key:z6Mk... identifier, signs a JWT, done. No certificates, no profile updates needed.

📋 Background

What is did:key?

A Decentralized Identifier method where the public key IS the identifier:

did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH
         └─────────────────┬──────────────────────┘
                    base58btc(public key)

The public key is embedded in the DID itself - no need to fetch it from anywhere.

How it works

  1. Agent generates keypair (ed25519 or P-256)
  2. Derives did:key from public key
  3. Creates self-signed JWT with claims:
    {
      "sub": "did:key:z6Mk...",
      "iss": "did:key:z6Mk...",
      "client_id": "did:key:z6Mk...",
      "aud": "https://solid.social/",
      "exp": 1234567890,
      "iat": 1234567800
    }
  4. Signs JWT with private key
  5. Sends Authorization: Bearer <jwt>
  6. Server extracts public key from DID
  7. Verifies JWT signature

Why this is better than WebID-TLS

WebID-TLS did:key
Generate X.509 certificate Generate keypair
Add cert to WebID profile DID is self-describing
HTTPS required Works over HTTP
Complex setup Simple CLI workflow
RSA (large keys) Ed25519 (compact)

🔧 Technical Specification

LWS10 Requirements

Per W3C LWS Authentication Suite:

Required JWT Claims:

  • sub, iss, client_id: All MUST use same did:key: URI
  • aud: MUST include target authorization server
  • exp: Token expiration timestamp
  • iat: Token creation timestamp
  • alg: Cannot be "none"

Validation:

  1. Extract public key from did:key identifier (per DID Key spec)
  2. Verify JWT signature using extracted key (RFC 7515)
  3. Validate claims (timestamps, audience, triple equality of sub/iss/client_id)
  4. Optional: Allow clock skew tolerance (±5 min)

Supported Key Types

  • Ed25519 (recommended): did:key:z6Mk... (multicodec 0xed)
  • P-256: did:key:zDna... (multicodec 0x1200)
  • secp256k1: did:key:zQ3s... (multicodec 0xe7)

🛠️ Implementation Plan

Phase 1: Core Authentication (~2-3 hours)

New file: src/auth/did-key.js

import { jwtVerify, importJWK } from 'jose';
import { base58btc } from 'multiformats/bases/base58';
import * as ed25519 from '@noble/ed25519';

/**
 * Verify did:key JWT authentication
 * @param {string} token - Bearer token
 * @param {string} audience - Expected audience (server URL)
 * @returns {string} - WebID derived from did:key
 */
export async function verifyDidKeyJWT(token, audience) {
  // Decode without verification to get DID
  const { payload } = await jwtVerify(token, async (header, token) => {
    const did = token.payload.sub;
    
    if (!did?.startsWith('did:key:')) {
      throw new Error('Invalid DID format');
    }
    
    // Extract public key from did:key
    const publicKey = await didKeyToPublicKey(did);
    return publicKey;
  });
  
  // Validate LWS10 constraints
  if (payload.sub !== payload.iss || payload.sub !== payload.client_id) {
    throw new Error('sub, iss, and client_id must be identical');
  }
  
  if (!Array.isArray(payload.aud) || !payload.aud.includes(audience)) {
    throw new Error('Invalid audience');
  }
  
  if (!payload.exp || !payload.iat) {
    throw new Error('Missing exp or iat claims');
  }
  
  // Return WebID (in our case, same as DID for now)
  return payload.sub;
}

/**
 * Extract public key from did:key identifier
 * Supports ed25519, P-256, secp256k1
 */
async function didKeyToPublicKey(did) {
  const multibaseKey = did.replace('did:key:', '');
  const bytes = base58btc.decode(multibaseKey);
  
  // First 2 bytes are multicodec prefix
  const codec = (bytes[0] << 8) | bytes[1];
  const publicKeyBytes = bytes.slice(2);
  
  switch (codec) {
    case 0xed: // Ed25519
      return await ed25519PublicKeyToJWK(publicKeyBytes);
    case 0x1200: // P-256
      return await p256PublicKeyToJWK(publicKeyBytes);
    default:
      throw new Error(`Unsupported key type: 0x${codec.toString(16)}`);
  }
}

async function ed25519PublicKeyToJWK(publicKeyBytes) {
  return {
    kty: 'OKP',
    crv: 'Ed25519',
    x: Buffer.from(publicKeyBytes).toString('base64url')
  };
}

Update: src/auth/middleware.js

import { verifyDidKeyJWT } from './did-key.js';

// In authorize() function, add did:key support:
if (authHeader?.startsWith('Bearer ')) {
  const token = authHeader.slice(7);
  
  try {
    // Try did:key JWT first
    const webId = await verifyDidKeyJWT(token, request.hostname);
    return { authorized: true, webId };
  } catch (didKeyErr) {
    // Fall through to existing token/OIDC validation
  }
}

Phase 2: CLI Tool Support (~1 hour)

New command: jss auth generate-did-key

// bin/jss.js
program
  .command('auth')
  .command('generate-did-key')
  .description('Generate did:key credentials for agent authentication')
  .action(async () => {
    const { privateKey, publicKey } = await ed25519.utils.randomPrivateKey();
    const did = await publicKeyToDidKey(publicKey);
    
    console.log('Generated did:key credentials:');
    console.log(`\nDID: ${did}`);
    console.log(`Private Key (hex): ${Buffer.from(privateKey).toString('hex')}`);
    console.log('\nSave private key securely. Use it to sign JWTs for authentication.');
  });

Phase 3: Documentation (~30 min)

Add to README.md:

### did:key Authentication (Agents & Bots)

For autonomous agents, CLI tools, and backend services:

**Generate credentials:**
```bash
jss auth generate-did-key
# DID: did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH
# Private Key: 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60

Create JWT and authenticate:

import { SignJWT } from 'jose';
import * as ed25519 from '@noble/ed25519';

const privateKeyHex = '9d61b19...';
const did = 'did:key:z6Mk...';

const jwt = await new SignJWT({})
  .setProtectedHeader({ alg: 'EdDSA' })
  .setSubject(did)
  .setIssuer(did)
  .setAudience('https://solid.social/')
  .claim('client_id', did)
  .setExpirationTime('1h')
  .setIssuedAt()
  .sign(privateKeyFromHex(privateKeyHex));

// Use JWT
const res = await fetch('https://solid.social/alice/private/', {
  headers: { 'Authorization': `Bearer ${jwt}` }
});

Benefits:

  • No certificates or profile setup
  • Self-describing identifiers
  • Standard JWT format
  • Works over HTTP or HTTPS

### Phase 4: Testing (~1 hour)

**New file:** `test/did-key.test.js`

```javascript
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { SignJWT, generateKeyPair } from 'jose';
import { verifyDidKeyJWT } from '../src/auth/did-key.js';

describe('did:key Authentication', () => {
  it('should verify valid did:key JWT', async () => {
    const { publicKey, privateKey } = await generateKeyPair('EdDSA');
    const did = await publicKeyToDidKey(publicKey);
    
    const jwt = await new SignJWT({})
      .setProtectedHeader({ alg: 'EdDSA' })
      .setSubject(did)
      .setIssuer(did)
      .setAudience('https://example.com/')
      .claim('client_id', did)
      .setExpirationTime('1h')
      .setIssuedAt()
      .sign(privateKey);
    
    const webId = await verifyDidKeyJWT(jwt, 'https://example.com/');
    assert.strictEqual(webId, did);
  });
  
  it('should reject mismatched sub/iss/client_id', async () => {
    // Test validation logic
  });
  
  it('should reject invalid audience', async () => {
    // Test audience validation
  });
});

📦 Dependencies

New:

  • multiformats - Multibase/multicodec decoding
  • @noble/ed25519 - Ed25519 crypto operations

Existing (already installed):

  • jose - JWT verification

🎯 Use Cases

  1. CLI tools - jss-cli authenticates to remote pods
  2. Backend bots - Automated agents that manage resources
  3. Server-to-server - Microservices accessing pod data
  4. IoT devices - Resource-constrained agents (Ed25519 is tiny)
  5. LWS protocol compliance - Interop with other LWS implementations

🔗 References

🚀 Acceptance Criteria

  • Verify Ed25519 did:key JWTs
  • Support P-256 did:key JWTs
  • Validate all LWS10 required claims
  • CLI command to generate did:key credentials
  • Integration with existing auth middleware
  • Tests covering validation rules
  • Documentation with examples
  • No breaking changes to existing auth methods

Estimated effort: 4-5 hours total
Priority: Medium (nice alternative to WebID-TLS)
Dependencies: None (adds to existing auth stack)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions