Barazo AppView backend
barazo.forum
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}