tinyrelayfile
tinyrelay/scripts/git-signing-proxy.mjs/

scripts/git-signing-proxy.mjs raw
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.
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";
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.
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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs