@binoctal/open-agents-bridge 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/install.js +94 -29
  2. package/package.json +3 -2
package/install.js CHANGED
@@ -64,19 +64,19 @@ function fetchJSON(url) {
64
64
  });
65
65
  }
66
66
 
67
- function fetchText(url) {
67
+ function fetchBuffer(url) {
68
68
  return new Promise((resolve, reject) => {
69
69
  https
70
70
  .get(url, { headers: { "User-Agent": "open-agents-bridge-npm" } }, (res) => {
71
71
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
72
- return fetchText(res.headers.location).then(resolve, reject);
72
+ return fetchBuffer(res.headers.location).then(resolve, reject);
73
73
  }
74
74
  if (res.statusCode !== 200) {
75
75
  return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
76
76
  }
77
- let data = "";
78
- res.on("data", (chunk) => (data += chunk));
79
- res.on("end", () => resolve(data));
77
+ const chunks = [];
78
+ res.on("data", (chunk) => chunks.push(chunk));
79
+ res.on("end", () => resolve(Buffer.concat(chunks)));
80
80
  })
81
81
  .on("error", reject);
82
82
  });
@@ -86,42 +86,103 @@ function sha256(filePath) {
86
86
  return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
87
87
  }
88
88
 
89
- // Verify the download against the release's own checksums.txt. A missing
90
- // checksums.txt aborts rather than skipping the check: whoever can swap an
91
- // asset can also make checksums.txt 404, so "skip when absent" verifies
92
- // nothing at all. The bridge is a long-lived daemon that takes commands from
93
- // a remote, and install time is the one cheap chance to confirm the bytes are
94
- // the ones that were published.
95
- async function verifyChecksum(release, asset, filePath) {
96
- const checksums = release.assets.find((a) => a.name === "checksums.txt");
97
- if (!checksums) {
98
- fs.unlinkSync(filePath);
99
- throw new Error(
100
- `Release ${release.tag_name} has no checksums.txt; refusing to install an unverified binary`
101
- );
89
+ // Release signing keys (5e.2): base64 raw Ed25519 public keys. A list so a
90
+ // key can be rotated; keep in sync with trustedKeys in
91
+ // internal/updater/signature.go. See docs/release-signing.md.
92
+ const TRUSTED_KEYS = ["NTmWyMB+MN8QmQ+R0TXJ3t3cwuqki1Cxld9HzHzc4Vw="];
93
+
94
+ // Transitional policy: a release without checksums.txt.sig is refused while
95
+ // this is true.
96
+ const REQUIRE_SIGNATURE = true;
97
+
98
+ const SIGNATURE_ASSET = "checksums.txt.sig";
99
+
100
+ // DER SubjectPublicKeyInfo prefix for a raw Ed25519 public key.
101
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
102
+
103
+ function verifySignature(checksumsBuf, sigText, keys) {
104
+ const sig = Buffer.from(String(sigText).trim(), "base64");
105
+ if (sig.length !== 64) {
106
+ throw new Error(`malformed ${SIGNATURE_ASSET}`);
107
+ }
108
+ for (const k of keys) {
109
+ const raw = Buffer.from(k, "base64");
110
+ if (raw.length !== 32) continue;
111
+ const key = crypto.createPublicKey({
112
+ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
113
+ format: "der",
114
+ type: "spki",
115
+ });
116
+ if (crypto.verify(null, checksumsBuf, key, sig)) return;
102
117
  }
118
+ throw new Error(`${SIGNATURE_ASSET} does not verify against any trusted release key`);
119
+ }
103
120
 
104
- const text = await fetchText(checksums.browser_download_url);
105
- const line = text
121
+ // Pure verification core. Order matters: the signature over checksums.txt is
122
+ // checked FIRST, then the archive hash against the authenticated checksums.
123
+ // Otherwise an attacker who can replace release assets replaces the archive
124
+ // and checksums.txt together. Throws on any failure; never deletes anything.
125
+ function verifyDownload({ checksumsBuf, sigText, assetName, filePath, keys, requireSignature }) {
126
+ if (sigText == null) {
127
+ if (requireSignature) {
128
+ throw new Error(`Release has no ${SIGNATURE_ASSET}; refusing to install an unsigned binary`);
129
+ }
130
+ } else {
131
+ verifySignature(checksumsBuf, sigText, keys);
132
+ }
133
+
134
+ const line = checksumsBuf
135
+ .toString("utf8")
106
136
  .split("\n")
107
137
  .map((l) => l.trim().split(/\s+/))
108
- .find((parts) => parts[1] === asset.name || parts[1] === `*${asset.name}`);
138
+ .find((parts) => parts[1] === assetName || parts[1] === `*${assetName}`);
109
139
 
110
140
  if (!line) {
111
- fs.unlinkSync(filePath);
112
- throw new Error(`checksums.txt does not list ${asset.name}`);
141
+ throw new Error(`checksums.txt does not list ${assetName}`);
113
142
  }
114
143
 
115
144
  const expected = line[0].toLowerCase();
116
145
  const actual = sha256(filePath);
117
146
  if (expected !== actual) {
147
+ throw new Error(
148
+ `Checksum mismatch for ${assetName}\n expected: ${expected}\n actual: ${actual}`
149
+ );
150
+ }
151
+ return actual;
152
+ }
153
+
154
+ // Verify the download against the release's signed checksums.txt. A missing
155
+ // checksums.txt aborts rather than skipping the check: whoever can swap an
156
+ // asset can also make checksums.txt 404, so "skip when absent" verifies
157
+ // nothing at all. The bridge is a long-lived daemon that takes commands from
158
+ // a remote, and install time is the one cheap chance to confirm the bytes are
159
+ // the ones that were published.
160
+ async function verifyChecksum(release, asset, filePath) {
161
+ const checksums = release.assets.find((a) => a.name === "checksums.txt");
162
+ if (!checksums) {
118
163
  fs.unlinkSync(filePath);
119
164
  throw new Error(
120
- `Checksum mismatch for ${asset.name}\n expected: ${expected}\n actual: ${actual}`
165
+ `Release ${release.tag_name} has no checksums.txt; refusing to install an unverified binary`
121
166
  );
122
167
  }
168
+ const sigAsset = release.assets.find((a) => a.name === SIGNATURE_ASSET);
123
169
 
124
- console.log(`Checksum verified (sha256 ${actual.slice(0, 16)}…)`);
170
+ try {
171
+ const checksumsBuf = await fetchBuffer(checksums.browser_download_url);
172
+ const sigText = sigAsset ? (await fetchBuffer(sigAsset.browser_download_url)).toString("utf8") : null;
173
+ const actual = verifyDownload({
174
+ checksumsBuf,
175
+ sigText,
176
+ assetName: asset.name,
177
+ filePath,
178
+ keys: TRUSTED_KEYS,
179
+ requireSignature: REQUIRE_SIGNATURE,
180
+ });
181
+ console.log(`Signature and checksum verified (sha256 ${actual.slice(0, 16)}…)`);
182
+ } catch (err) {
183
+ fs.unlinkSync(filePath);
184
+ throw err;
185
+ }
125
186
  }
126
187
 
127
188
  function downloadFile(url, destPath) {
@@ -249,7 +310,11 @@ async function install() {
249
310
  console.log(`Successfully installed open-agents-bridge ${release.tag_name}`);
250
311
  }
251
312
 
252
- install().catch((err) => {
253
- console.error("Installation failed:", err.message);
254
- process.exit(1);
255
- });
313
+ if (require.main === module) {
314
+ install().catch((err) => {
315
+ console.error("Installation failed:", err.message);
316
+ process.exit(1);
317
+ });
318
+ }
319
+
320
+ module.exports = { verifySignature, verifyDownload, TRUSTED_KEYS };
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@binoctal/open-agents-bridge",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Bridge CLI for Open Agents - connect your machines to the agent network",
5
5
  "bin": {
6
6
  "open-agents-bridge": "./run.js"
7
7
  },
8
8
  "scripts": {
9
- "postinstall": "node install.js"
9
+ "postinstall": "node install.js",
10
+ "test": "node --test"
10
11
  },
11
12
  "files": [
12
13
  "install.js",