clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / scripts / git-signing-proxy.mjs
git-signing-proxy.mjs 3.8K
1// Local NIP-98 signing proxy for native Git's smart HTTP requests. Git talks2// plain HTTP to this proxy; each request is hashed, signed as a kind 272353// proof by the supplied signer, and forwarded to the relay with the proof in4// the Authorization header. Request and response bodies stream through5// files and pipes rather than accumulating in memory.6import http from "node:http";7import https from "node:https";8import { createHash, randomUUID } from "node:crypto";9import { createReadStream, createWriteStream } from "node:fs";10import { mkdtemp, rm } from "node:fs/promises";11import { tmpdir } from "node:os";12import { join } from "node:path";13import { Transform } from "node:stream";14import { pipeline } from "node:stream/promises";15import { finalizeEvent } from "nostr-tools/pure";1617// startGitSigningProxy forwards to target (an http or https base URL). The18// signer is either a secret key (Uint8Array) or an async function that takes19// an event template and returns the signed event, such as a NIP-46 bunker.20export async function startGitSigningProxy(target, signer, options = {}) {21 const sign = typeof signer === "function" ? signer : template => finalizeEvent(template, signer);22 const server = http.createServer(async (request, response) => {23 let directory;24 try {25 directory = await mkdtemp(join(tmpdir(), "tiny-git-signing-"));26 const path = join(directory, "request");27 const hash = createHash("sha256");28 let size = 0;29 await pipeline(request, new Transform({transform(chunk, encoding, done) {30 hash.update(chunk); size += chunk.length; done(null, chunk);31 }}), createWriteStream(path));32 const url = new URL(target + request.url);33 let proofURL = url.href;34 let proofMethod = request.method;35 if (options.grasp08) {36 const marker = url.pathname.lastIndexOf(".git");37 if (marker < 0 || !["", "/"].includes(url.pathname.slice(marker + 4, marker + 5))) {38 throw Error("GRASP08 signing requires a repository URL");39 }40 proofURL = new URL(url.href);41 proofURL.pathname = url.pathname.slice(0, marker + 4);42 proofURL.search = "";43 proofURL.hash = "";44 proofURL = proofURL.href;45 proofMethod = "GET";46 }47 const tags = [["u", proofURL], ["method", proofMethod], ["nonce", randomUUID()]];48 if (size && !options.grasp08) tags.push(["payload", hash.digest("hex")]);49 const proof = await sign({kind: 27235, created_at: Math.floor(Date.now()/1000), tags, content: ""});50 const headers = {...request.headers, host: url.host, "content-length": String(size),51 authorization: "Nostr " + Buffer.from(JSON.stringify(proof)).toString("base64")};52 delete headers["transfer-encoding"];53 delete headers.connection;54 const client = url.protocol === "https:" ? https : http;55 let upstream;56 const received = new Promise((resolve, reject) => {57 upstream = client.request(url, {method: request.method, headers}, incoming => {58 response.writeHead(incoming.statusCode, incoming.headers);59 pipeline(incoming, response).then(resolve, reject);60 });61 upstream.once("error", reject);62 });63 await Promise.all([pipeline(createReadStream(path), upstream), received]);64 } catch (error) {65 if (response.headersSent) response.destroy(error);66 else { response.writeHead(502); response.end(error.message); }67 } finally {68 if (directory) await rm(directory, {recursive: true, force: true});69 }70 });71 await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });72 return {url: `http://127.0.0.1:${server.address().port}`,73 close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))};74}