whisper-id 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/index.d.ts +31 -0
- package/index.js +141 -0
- package/package.json +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 viaGraph B.V. (Whisper Security)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# whisper-id
|
|
2
|
+
|
|
3
|
+
A real, routable **IPv6 identity** and **safe egress** for any Node agent — in two calls.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i whisper-id
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { register, withEgress } from "whisper-id";
|
|
11
|
+
|
|
12
|
+
const agent = await register("my-bot"); // a routable Whisper /128 identity
|
|
13
|
+
await withEgress(async (e) => {
|
|
14
|
+
// proxy env (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) now points at your local Whisper proxy
|
|
15
|
+
// → curl, axios, got, and other env-aware clients leave from your /128.
|
|
16
|
+
console.log("egress on", e.proxyUrl);
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## API
|
|
21
|
+
|
|
22
|
+
| Call | Does |
|
|
23
|
+
|------|------|
|
|
24
|
+
| `register(name, { newKey })` | Create a named agent — a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
|
|
25
|
+
| `egress({ agent, tier, setEnv })` | Bring up egress bound to your `/128`. → `Egress { port, proxyUrl, socksUrl, proxies, close() }`. `tier: "wireguard"` for a routed `/128`. Sets proxy env unless `setEnv:false`; call `close()` to restore. |
|
|
26
|
+
| `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
|
|
27
|
+
| `verify(address)` | Keyless — is `address` a real Whisper agent? (DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
|
|
28
|
+
| `ip()` | Your current egress IP, proving it's your `/128`. → `string` |
|
|
29
|
+
|
|
30
|
+
### Using it with `fetch`
|
|
31
|
+
|
|
32
|
+
Node's global `fetch` (undici) does **not** read proxy env vars, so pass a dispatcher:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { egress } from "whisper-id";
|
|
36
|
+
import { ProxyAgent } from "undici"; // npm i undici
|
|
37
|
+
|
|
38
|
+
const e = await egress();
|
|
39
|
+
const res = await fetch("https://api64.ipify.org", { dispatcher: new ProxyAgent(e.proxyUrl) });
|
|
40
|
+
console.log(await res.text()); // ← your /128
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`axios` / `got` can take `e.proxyUrl` directly; CLI tools and env-aware libraries work
|
|
44
|
+
transparently inside `withEgress`.
|
|
45
|
+
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
The `whisper` CLI on your `PATH` (this package is a thin, dependency-free wrapper over it):
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
curl get.whisper.online | sh
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For authenticated calls (`register`, `egress`, `ip`), set `WHISPER_API_KEY` or run
|
|
55
|
+
`whisper login`. `verify` is keyless. (`$WHISPER_BIN` overrides the CLI path.)
|
|
56
|
+
|
|
57
|
+
## Links
|
|
58
|
+
|
|
59
|
+
- Site — https://whisper.online
|
|
60
|
+
- CLI — https://github.com/whisper-sec/whisper-cli
|
|
61
|
+
|
|
62
|
+
MIT licensed.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 viaGraph B.V. (Whisper Security)
|
|
3
|
+
export declare class WhisperError extends Error {}
|
|
4
|
+
|
|
5
|
+
export interface Agent {
|
|
6
|
+
/** The routable IPv6 /128 — both the identity and the auth. */
|
|
7
|
+
address: string;
|
|
8
|
+
id: string | null;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Egress {
|
|
13
|
+
port: number;
|
|
14
|
+
address: string | null;
|
|
15
|
+
proxyUrl: string;
|
|
16
|
+
socksUrl: string;
|
|
17
|
+
proxies: { http: string; https: string };
|
|
18
|
+
/** Restore the proxy environment variables to their prior values. */
|
|
19
|
+
close(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RegisterOptions { newKey?: boolean; timeout?: number; }
|
|
23
|
+
export interface EgressOptions { agent?: string; tier?: string; setEnv?: boolean; timeout?: number; }
|
|
24
|
+
|
|
25
|
+
export function register(name: string, opts?: RegisterOptions): Promise<Agent>;
|
|
26
|
+
export function egress(opts?: EgressOptions): Promise<Egress>;
|
|
27
|
+
export function withEgress<T>(fn: (e: Egress) => Promise<T> | T): Promise<T>;
|
|
28
|
+
export function withEgress<T>(opts: EgressOptions, fn: (e: Egress) => Promise<T> | T): Promise<T>;
|
|
29
|
+
export function verify(address: string, opts?: { timeout?: number }): Promise<boolean>;
|
|
30
|
+
export function ip(opts?: { timeout?: number }): Promise<string>;
|
|
31
|
+
export function firstAddr(obj: unknown): string | null;
|
package/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 viaGraph B.V. (Whisper Security)
|
|
3
|
+
// whisper-id — a routable IPv6 identity and safe egress for any Node agent.
|
|
4
|
+
// A thin, dependency-free wrapper over the `whisper` CLI (https://whisper.online): the CLI
|
|
5
|
+
// holds the auth, control plane, and egress tunnel; this gives you register/egress/verify/ip.
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const pexec = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
const ADDR_RE = /2a04:2a01:[0-9a-fA-F:]{2,}/; // the public Whisper agent prefix (AS219419)
|
|
12
|
+
const HOSTPORT_RE = /127\.0\.0\.1:(\d{2,5})/;
|
|
13
|
+
const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
14
|
+
|
|
15
|
+
/** Error thrown when a `whisper` invocation fails, or the CLI is not installed. */
|
|
16
|
+
export class WhisperError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "WhisperError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bin() {
|
|
24
|
+
return process.env.WHISPER_BIN || "whisper";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function run(args, { timeout = 120000, check = true } = {}) {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout, stderr } = await pexec(bin(), args, { timeout, encoding: "utf8" });
|
|
30
|
+
return { stdout: stdout || "", stderr: stderr || "", code: 0 };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err && err.code === "ENOENT") {
|
|
33
|
+
throw new WhisperError(
|
|
34
|
+
"the `whisper` CLI was not found on PATH. Install it with " +
|
|
35
|
+
"`curl get.whisper.online | sh` (see https://whisper.online)."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const out = { stdout: err.stdout || "", stderr: err.stderr || "", code: typeof err.code === "number" ? err.code : 1 };
|
|
39
|
+
if (check) {
|
|
40
|
+
const detail = (out.stderr || out.stdout || err.message || `exit ${out.code}`).toString().trim();
|
|
41
|
+
throw new WhisperError(`\`whisper ${args.join(" ")}\` failed: ${detail.slice(0, 300)}`);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function runJson(args, opts) {
|
|
48
|
+
const { stdout } = await run(["--json", ...args], opts);
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(stdout.trim());
|
|
51
|
+
} catch {
|
|
52
|
+
throw new WhisperError(`could not parse JSON from \`whisper ${args.join(" ")}\`: ${stdout.trim().slice(0, 200)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function firstAddr(obj) {
|
|
57
|
+
const m = ADDR_RE.exec(JSON.stringify(obj));
|
|
58
|
+
return m ? m[0] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getKey(obj, ...keys) {
|
|
62
|
+
if (obj && typeof obj === "object") {
|
|
63
|
+
for (const k of keys) if (obj[k]) return obj[k];
|
|
64
|
+
for (const v of Object.values(obj)) {
|
|
65
|
+
if (v && typeof v === "object") {
|
|
66
|
+
const got = getKey(v, ...keys);
|
|
67
|
+
if (got) return got;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Create a named agent identity — a routable Whisper IPv6 /128. */
|
|
75
|
+
export async function register(name, { newKey = false, timeout = 120000 } = {}) {
|
|
76
|
+
if (!name || !name.trim()) throw new WhisperError("register() needs a non-empty agent name");
|
|
77
|
+
const args = ["create", "--name", name];
|
|
78
|
+
if (newKey) args.push("--register");
|
|
79
|
+
const env = await runJson(args, { timeout });
|
|
80
|
+
const address = firstAddr(env);
|
|
81
|
+
if (!address) throw new WhisperError(`no /128 returned by \`whisper create\`: ${JSON.stringify(env).slice(0, 200)}`);
|
|
82
|
+
return { address, id: getKey(env, "id", "agent_id", "agentId"), name };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeEgress(port, address) {
|
|
86
|
+
const proxyUrl = `http://127.0.0.1:${port}`;
|
|
87
|
+
const socksUrl = `socks5h://127.0.0.1:${port}`;
|
|
88
|
+
return { port, address: address || null, proxyUrl, socksUrl, proxies: { http: proxyUrl, https: proxyUrl }, close() {} };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Bring egress up bound to your /128 (idempotent detached proxy). By default sets the standard
|
|
93
|
+
* proxy env vars; call the returned `.close()` to restore them. Prefer `withEgress` for scoping.
|
|
94
|
+
*/
|
|
95
|
+
export async function egress({ agent, tier = "socks5", setEnv = true, timeout = 90000 } = {}) {
|
|
96
|
+
const args = ["connect", "--ensure", "--tier", tier];
|
|
97
|
+
if (agent) args.push("--agent", agent);
|
|
98
|
+
const { stdout, stderr } = await run(args, { timeout });
|
|
99
|
+
const m = HOSTPORT_RE.exec(`${stdout}\n${stderr}`);
|
|
100
|
+
if (!m) throw new WhisperError(`could not determine the local proxy port from \`whisper connect\`: ${`${stdout}${stderr}`.trim().slice(0, 200)}`);
|
|
101
|
+
const e = makeEgress(Number(m[1]), agent);
|
|
102
|
+
const saved = {};
|
|
103
|
+
if (setEnv) {
|
|
104
|
+
for (const k of PROXY_VARS) saved[k] = process.env[k];
|
|
105
|
+
process.env.HTTP_PROXY = process.env.http_proxy = e.proxyUrl;
|
|
106
|
+
process.env.HTTPS_PROXY = process.env.https_proxy = e.proxyUrl;
|
|
107
|
+
process.env.ALL_PROXY = process.env.all_proxy = e.socksUrl;
|
|
108
|
+
}
|
|
109
|
+
e.close = () => {
|
|
110
|
+
for (const k of PROXY_VARS) {
|
|
111
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
112
|
+
else process.env[k] = saved[k];
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return e;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Run `fn(egress)` with egress up, restoring the environment afterwards. */
|
|
119
|
+
export async function withEgress(optsOrFn, maybeFn) {
|
|
120
|
+
const fn = typeof optsOrFn === "function" ? optsOrFn : maybeFn;
|
|
121
|
+
const opts = typeof optsOrFn === "function" ? {} : optsOrFn || {};
|
|
122
|
+
const e = await egress(opts);
|
|
123
|
+
try {
|
|
124
|
+
return await fn(e);
|
|
125
|
+
} finally {
|
|
126
|
+
e.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Return true iff `address` is a real Whisper agent (DANE+DNSSEC+reverse-DNS+JWS). Keyless. */
|
|
131
|
+
export async function verify(address, { timeout = 60000 } = {}) {
|
|
132
|
+
if (!address || !address.trim()) throw new WhisperError("verify() needs an address");
|
|
133
|
+
const { code } = await run(["verify", address], { timeout, check: false });
|
|
134
|
+
return code === 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Return the current egress IP, proving it is your Whisper /128. */
|
|
138
|
+
export async function ip({ timeout = 60000 } = {}) {
|
|
139
|
+
const env = await runJson(["ip"], { timeout });
|
|
140
|
+
return getKey(env, "ip", "agent") || firstAddr(env) || "";
|
|
141
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whisper-id",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A routable Whisper IPv6 identity and safe egress for any Node agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": { ".": { "types": "./index.d.ts", "import": "./index.js" } },
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"files": ["index.js", "index.d.ts", "README.md", "LICENSE"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"scripts": { "test": "node --test" },
|
|
11
|
+
"keywords": ["whisper", "agent", "identity", "ipv6", "egress", "proxy", "dns"],
|
|
12
|
+
"homepage": "https://whisper.online",
|
|
13
|
+
"repository": { "type": "git", "url": "https://github.com/whisper-sec/whisper-node.git" },
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Whisper Security"
|
|
16
|
+
}
|