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

    enhancementNew feature or request

    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