Barazo AppView backend barazo.forum
at main 321 lines 9.7 kB view raw
1import { createHash, createHmac } from 'node:crypto' 2import * as secp256k1 from '@noble/secp256k1' 3import * as dagCbor from '@ipld/dag-cbor' 4import type { Logger } from '../lib/logger.js' 5 6// --------------------------------------------------------------------------- 7// Configure @noble/secp256k1 v3 sync hashes (required for sync sign/verify) 8// Uses Node.js built-in crypto instead of @noble/hashes dependency. 9// --------------------------------------------------------------------------- 10 11secp256k1.hashes.hmacSha256 = (key: Uint8Array, message: Uint8Array) => { 12 return new Uint8Array(createHmac('sha256', key).update(message).digest()) 13} 14 15secp256k1.hashes.sha256 = (message: Uint8Array) => { 16 return new Uint8Array(createHash('sha256').update(message).digest()) 17} 18 19// --------------------------------------------------------------------------- 20// Constants 21// --------------------------------------------------------------------------- 22 23const DEFAULT_PLC_DIRECTORY_URL = 'https://plc.directory' 24 25/** 26 * Multicodec prefix for secp256k1 public keys. 27 * Varint-encoded 0xe7 = [0xe7, 0x01]. 28 */ 29const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]) 30 31// --------------------------------------------------------------------------- 32// Types 33// --------------------------------------------------------------------------- 34 35/** Parameters for generating a PLC DID. */ 36export interface GenerateDidParams { 37 /** Community handle, e.g. "community.barazo.forum" */ 38 handle: string 39 /** Community service endpoint, e.g. "https://community.barazo.forum" */ 40 serviceEndpoint: string 41 /** PLC directory URL. Defaults to https://plc.directory */ 42 plcDirectoryUrl?: string 43} 44 45/** Result of PLC DID generation. */ 46export interface GenerateDidResult { 47 /** The generated DID, e.g. "did:plc:abc123..." */ 48 did: string 49 /** Hex-encoded signing private key */ 50 signingKey: string 51 /** Hex-encoded rotation private key */ 52 rotationKey: string 53} 54 55/** PLC genesis operation (unsigned). */ 56export interface PlcGenesisOperation { 57 type: 'plc_operation' 58 rotationKeys: string[] 59 verificationMethods: { 60 atproto: string 61 } 62 alsoKnownAs: string[] 63 services: { 64 atproto_pds: { 65 type: 'AtprotoPersonalDataServer' 66 endpoint: string 67 } 68 } 69 prev: null 70} 71 72/** PLC genesis operation with signature. */ 73export interface SignedPlcOperation extends PlcGenesisOperation { 74 sig: string 75} 76 77/** PLC DID service interface for dependency injection and testing. */ 78export interface PlcDidService { 79 generateDid(params: GenerateDidParams): Promise<GenerateDidResult> 80} 81 82// --------------------------------------------------------------------------- 83// Internal helpers 84// --------------------------------------------------------------------------- 85 86/** 87 * Base32 encode bytes using RFC 4648 lowercase alphabet, no padding. 88 * Used for PLC DID computation. 89 */ 90export function base32Encode(bytes: Uint8Array): string { 91 const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' 92 let bits = 0 93 let value = 0 94 let output = '' 95 96 for (const byte of bytes) { 97 value = (value << 8) | byte 98 bits += 8 99 while (bits >= 5) { 100 bits -= 5 101 const char = alphabet[(value >>> bits) & 31] 102 if (char !== undefined) output += char 103 } 104 } 105 106 if (bits > 0) { 107 const char = alphabet[(value << (5 - bits)) & 31] 108 if (char !== undefined) output += char 109 } 110 111 return output 112} 113 114/** 115 * Encode a compressed secp256k1 public key as a did:key string. 116 * 117 * Format: "did:key:z" + base58btc(multicodec_prefix + compressed_pubkey) 118 * 119 * The multicodec prefix for secp256k1 is 0xe7 (varint-encoded as [0xe7, 0x01]). 120 * Base58btc uses the 'z' multibase prefix. 121 */ 122export function compressedPubKeyToDidKey(pubKey: Uint8Array): string { 123 // Concatenate multicodec prefix + compressed public key 124 const prefixed = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + pubKey.length) 125 prefixed.set(SECP256K1_MULTICODEC_PREFIX, 0) 126 prefixed.set(pubKey, SECP256K1_MULTICODEC_PREFIX.length) 127 128 // Base58btc encode (with 'z' multibase prefix) 129 const encoded = base58btcEncode(prefixed) 130 return `did:key:z${encoded}` 131} 132 133/** 134 * Base58btc encoding using the Bitcoin alphabet. 135 */ 136export function base58btcEncode(bytes: Uint8Array): string { 137 const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 138 139 // Count leading zeros 140 let leadingZeros = 0 141 for (const b of bytes) { 142 if (b !== 0) break 143 leadingZeros++ 144 } 145 146 // Convert bytes to a BigInt 147 let num = 0n 148 for (const b of bytes) { 149 num = num * 256n + BigInt(b) 150 } 151 152 // Encode to base58 153 let encoded = '' 154 while (num > 0n) { 155 const remainder = Number(num % 58n) 156 num = num / 58n 157 const char = ALPHABET[remainder] ?? '' 158 encoded = char + encoded 159 } 160 161 // Add leading '1's for each leading zero byte 162 return '1'.repeat(leadingZeros) + encoded 163} 164 165/** 166 * Build a PLC genesis operation (unsigned). 167 */ 168export function buildGenesisOperation( 169 signingPubKeyDidKey: string, 170 rotationPubKeyDidKey: string, 171 handle: string, 172 serviceEndpoint: string 173): PlcGenesisOperation { 174 return { 175 type: 'plc_operation', 176 rotationKeys: [rotationPubKeyDidKey], 177 verificationMethods: { 178 atproto: signingPubKeyDidKey, 179 }, 180 alsoKnownAs: [`at://${handle}`], 181 services: { 182 atproto_pds: { 183 type: 'AtprotoPersonalDataServer', 184 endpoint: serviceEndpoint, 185 }, 186 }, 187 prev: null, 188 } 189} 190 191/** 192 * Sign a PLC genesis operation with the rotation key. 193 * 194 * Steps: 195 * 1. CBOR-encode the unsigned operation 196 * 2. SHA-256 hash the CBOR bytes 197 * 3. Sign the hash with the rotation private key (prehash disabled since we hash manually) 198 * 4. Encode signature as base64url 199 */ 200export function signGenesisOperation( 201 operation: PlcGenesisOperation, 202 rotationPrivKey: Uint8Array 203): SignedPlcOperation { 204 const cborBytes = dagCbor.encode(operation) 205 const hash = createHash('sha256').update(cborBytes).digest() 206 207 // secp256k1 v3 sign() returns compact Bytes directly. 208 // prehash: false because we already SHA-256 hashed the CBOR bytes. 209 const sigBytes = secp256k1.sign(new Uint8Array(hash), rotationPrivKey, { 210 prehash: false, 211 }) 212 const sig = Buffer.from(sigBytes).toString('base64url') 213 214 return { ...operation, sig } 215} 216 217/** 218 * Compute the PLC DID from a signed genesis operation. 219 * 220 * DID = "did:plc:" + base32lower(sha256(cbor(signedOp))[:15]) 221 * 222 * The first 15 bytes (120 bits) of the SHA-256 hash are base32-encoded 223 * to produce a 24-character identifier. 224 */ 225export function computeDidFromSignedOperation(signedOp: SignedPlcOperation): string { 226 const cborBytes = dagCbor.encode(signedOp) 227 const hash = createHash('sha256').update(cborBytes).digest() 228 const truncated = hash.subarray(0, 15) 229 const encoded = base32Encode(new Uint8Array(truncated)) 230 return `did:plc:${encoded}` 231} 232 233/** 234 * Submit a signed PLC operation to plc.directory. 235 */ 236async function submitToPlcDirectory( 237 did: string, 238 signedOp: SignedPlcOperation, 239 plcDirectoryUrl: string, 240 logger: Logger 241): Promise<void> { 242 const url = `${plcDirectoryUrl}/${did}` 243 244 logger.info({ did, plcDirectoryUrl }, 'Submitting PLC genesis operation') 245 246 const response = await fetch(url, { 247 method: 'POST', 248 headers: { 'Content-Type': 'application/json' }, 249 body: JSON.stringify(signedOp), 250 }) 251 252 if (!response.ok) { 253 const body = await response.text() 254 logger.error({ did, status: response.status, body }, 'PLC directory rejected genesis operation') 255 throw new Error(`PLC directory returned ${String(response.status)}: ${body}`) 256 } 257 258 logger.info({ did }, 'PLC DID registered successfully') 259} 260 261// --------------------------------------------------------------------------- 262// Factory 263// --------------------------------------------------------------------------- 264 265/** 266 * Create a PLC DID service for generating and registering community DIDs. 267 * 268 * The service generates secp256k1 key pairs (signing + rotation), 269 * constructs a PLC genesis operation, signs it, submits to plc.directory, 270 * and returns the generated DID with private keys. 271 * 272 * @param logger - Pino logger instance 273 * @returns PlcDidService with generateDid method 274 */ 275export function createPlcDidService(logger: Logger): PlcDidService { 276 async function generateDid(params: GenerateDidParams): Promise<GenerateDidResult> { 277 const plcDirectoryUrl = params.plcDirectoryUrl ?? DEFAULT_PLC_DIRECTORY_URL 278 279 logger.info( 280 { handle: params.handle, serviceEndpoint: params.serviceEndpoint }, 281 'Generating PLC DID for community' 282 ) 283 284 // 1. Generate key pairs (v3: utils.randomSecretKey) 285 const signingPrivKey = secp256k1.utils.randomSecretKey() 286 const signingPubKey = secp256k1.getPublicKey(signingPrivKey, true) 287 288 const rotationPrivKey = secp256k1.utils.randomSecretKey() 289 const rotationPubKey = secp256k1.getPublicKey(rotationPrivKey, true) 290 291 // 2. Encode public keys as did:key 292 const signingDidKey = compressedPubKeyToDidKey(signingPubKey) 293 const rotationDidKey = compressedPubKeyToDidKey(rotationPubKey) 294 295 // 3. Build genesis operation 296 const genesisOp = buildGenesisOperation( 297 signingDidKey, 298 rotationDidKey, 299 params.handle, 300 params.serviceEndpoint 301 ) 302 303 // 4. Sign with rotation key 304 const signedOp = signGenesisOperation(genesisOp, rotationPrivKey) 305 306 // 5. Compute DID 307 const did = computeDidFromSignedOperation(signedOp) 308 309 // 6. Submit to plc.directory 310 await submitToPlcDirectory(did, signedOp, plcDirectoryUrl, logger) 311 312 // 7. Return DID and hex-encoded private keys 313 return { 314 did, 315 signingKey: Buffer.from(signingPrivKey).toString('hex'), 316 rotationKey: Buffer.from(rotationPrivKey).toString('hex'), 317 } 318 } 319 320 return { generateDid } 321}