| 1 | // Local NIP-98 signing proxy for native Git's smart HTTP requests. Git talks |
| 2 | // plain HTTP to this proxy; each request is hashed, signed as a kind 27235 |
| 3 | // proof by the supplied signer, and forwarded to the relay with the proof in |
| 4 | // the Authorization header. Request and response bodies stream through |
| 5 | // files and pipes rather than accumulating in memory. |
| 6 | import http from "node:http" ; |
| 7 | import https from "node:https" ; |
| 8 | import { createHash, randomUUID } from "node:crypto" ; |
| 9 | import { createReadStream, createWriteStream } from "node:fs" ; |
| 10 | import { mkdtemp, rm } from "node:fs/promises" ; |
| 11 | import { tmpdir } from "node:os" ; |
| 12 | import { join } from "node:path" ; |
| 13 | import { Transform } from "node:stream" ; |
| 14 | import { pipeline } from "node:stream/promises" ; |
| 15 | import { finalizeEvent } from "nostr-tools/pure" ; |
| 16 | |
| 17 | // startGitSigningProxy forwards to target (an http or https base URL). The |
| 18 | // signer is either a secret key (Uint8Array) or an async function that takes |
| 19 | // an event template and returns the signed event, such as a NIP-46 bunker. |
| 20 | export 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 | } |