nattermatic 0.0.0-stage → 0.2.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 +230 -2
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +229 -0
- package/package.json +64 -4
- package/src/cert.js +86 -0
- package/src/claude.js +406 -0
- package/src/cli.js +223 -0
- package/src/codex.js +247 -0
- package/src/config.js +280 -0
- package/src/crypto.js +124 -0
- package/src/discovery.js +60 -0
- package/src/legacy-env.js +4 -0
- package/src/legacy.js +100 -0
- package/src/local.js +193 -0
- package/src/mcp.js +370 -0
- package/src/node.js +1387 -0
- package/src/roster.js +517 -0
- package/src/self.js +41 -0
- package/src/setup.js +451 -0
- package/src/store.js +39 -0
- package/src/tls.js +229 -0
- package/src/types.d.ts +150 -0
- package/src/wake.js +143 -0
package/install.sh
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# nattermatic installer — wake-on-message networking for AI agents (Claude Code and others)
|
|
3
|
+
#
|
|
4
|
+
# New network: curl -fsSL https://nattermatic.com/install.sh | sh
|
|
5
|
+
# Join network: paste the one-time line printed by `natter invite` on a machine that's already set up
|
|
6
|
+
#
|
|
7
|
+
# Installs (no sudo): on Apple silicon Macs, nattermatic.app in ~/Applications; elsewhere (or if the
|
|
8
|
+
# app can't be fetched) a private Node.js runtime if a recent one isn't present and the nattermatic
|
|
9
|
+
# npm package. Then a background service, and nattermatic for every Claude Code account here.
|
|
10
|
+
set -e
|
|
11
|
+
#@@PRESET@@
|
|
12
|
+
# nattermatic was called agentlink until 0.2.0: AGENTLINK_* settings still count, unless the new name is set
|
|
13
|
+
for v in $(env | sed -n 's/^AGENTLINK_\([A-Z0-9_]*\)=.*/\1/p'); do
|
|
14
|
+
eval "[ -n \"\${NATTERMATIC_$v+x}\" ] || { NATTERMATIC_$v=\"\$AGENTLINK_$v\"; export NATTERMATIC_$v; }"
|
|
15
|
+
done
|
|
16
|
+
PKG="${NATTERMATIC_PKG:-nattermatic}"
|
|
17
|
+
NATTERMATIC_ORIG_PATH="$PATH"; export NATTERMATIC_ORIG_PATH
|
|
18
|
+
TMP=$(mktemp -d)
|
|
19
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
20
|
+
trap 'exit 1' HUP INT TERM # dash doesn't run the EXIT trap for these by itself
|
|
21
|
+
|
|
22
|
+
# Rosetta's shells call an Apple silicon Mac x86_64
|
|
23
|
+
PLATFORM="$(uname -s)-$(uname -m)"
|
|
24
|
+
if [ "$PLATFORM" = Darwin-x86_64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = 1 ]; then
|
|
25
|
+
PLATFORM=Darwin-arm64
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# what nattermatic needs: found out before anything is changed
|
|
29
|
+
MIN_CLAUDE=2.1.318 # WAKE_MIN_CLAUDE in src/claude.js
|
|
30
|
+
check_prereqs() {
|
|
31
|
+
[ -n "${NATTERMATIC_SKIP_CHECKS:-}" ] && return 0
|
|
32
|
+
if [ "$PLATFORM" = Darwin-x86_64 ]; then
|
|
33
|
+
echo "nattermatic: Intel Macs aren't supported yet (macOS may stop it reaching your other machines)." >&2
|
|
34
|
+
echo "To install it anyway: NATTERMATIC_SKIP_CHECKS=1" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
C=$(command -v claude 2>/dev/null || true)
|
|
38
|
+
for f in "$HOME/.local/bin/claude" "$HOME/.claude/local/claude"; do
|
|
39
|
+
if [ -z "$C" ] && [ -x "$f" ]; then C=$f; fi
|
|
40
|
+
done
|
|
41
|
+
if [ -z "$C" ]; then
|
|
42
|
+
# run by Claude Code itself: it's there, just not on this shell's PATH
|
|
43
|
+
[ -n "${CLAUDECODE:-}" ] && return 0
|
|
44
|
+
echo "nattermatic: Claude Code isn't installed. Install it first: https://claude.com/claude-code" >&2
|
|
45
|
+
echo "(For another MCP agent, set NATTERMATIC_SKIP_CHECKS=1.)" >&2
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
V=$("$C" --version 2>/dev/null | sed -n 's/^\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1)
|
|
49
|
+
[ -n "$V" ] || return 0
|
|
50
|
+
OLD=$(printf '%s\n%s\n' "$V" "$MIN_CLAUDE" | awk -F. 'NR==1 { a=$1; b=$2; c=$3 } NR==2 { print (a<$1 || (a==$1 && (b<$2 || (b==$2 && c<$3)))) ? 1 : 0 }')
|
|
51
|
+
if [ "$OLD" = 1 ]; then
|
|
52
|
+
echo "nattermatic: needs Claude Code $MIN_CLAUDE or newer, and this is $V. Update it (claude update), then run this again." >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
}
|
|
56
|
+
check_prereqs
|
|
57
|
+
|
|
58
|
+
# nattermatic's home holds this machine's private key, messages and logs: private to this user from the start
|
|
59
|
+
AL="${NATTERMATIC_HOME:-$HOME/.nattermatic}"
|
|
60
|
+
mkdir -p "$AL"
|
|
61
|
+
chmod 700 "$AL"
|
|
62
|
+
|
|
63
|
+
# The Apple Developer team that signs nattermatic.app: the app is installed only with its signature
|
|
64
|
+
TEAM="${NATTERMATIC_TEAM:-CZ63J37ULN}"
|
|
65
|
+
APPS="$HOME/Applications"
|
|
66
|
+
RELEASES=https://github.com/tombaldwin/nattermatic/releases/latest/download
|
|
67
|
+
|
|
68
|
+
# Fetch from the inviting machine, pinned to its invite key (-k only skips CA checks; curl still
|
|
69
|
+
# refuses any key but the pinned one). The URL holds the invite token, so it goes to curl in a
|
|
70
|
+
# private file, not on the command line (where `ps` would show it).
|
|
71
|
+
fetch_invite() {
|
|
72
|
+
printf 'url = "%s"\n' "$1" > "$TMP/curl.cfg"
|
|
73
|
+
if [ -n "${NATTERMATIC_PIN:-}" ]; then
|
|
74
|
+
curl -fsSL -k --pinnedpubkey "sha256//$NATTERMATIC_PIN" -K "$TMP/curl.cfg" -o "$2"
|
|
75
|
+
else
|
|
76
|
+
curl -fsSL -K "$TMP/curl.cfg" -o "$2"
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# nattermatic.app (Apple silicon): as a signed app, macOS asks for Local Network access by name and
|
|
81
|
+
# the permission covers nattermatic only. From the inviting machine when it runs the app, else from
|
|
82
|
+
# the latest GitHub release. Returns 1 if no app is available (the npm install is used instead).
|
|
83
|
+
# This function runs as an if condition, where set -e is off, so each step says what happens if it fails.
|
|
84
|
+
APP_EXE="$APPS/nattermatic.app/Contents/MacOS/nattermatic"
|
|
85
|
+
no_app() {
|
|
86
|
+
echo "nattermatic: could not download nattermatic.app ($1)."
|
|
87
|
+
if [ -x "$APP_EXE" ]; then
|
|
88
|
+
echo "Keeping the one already in $APPS."
|
|
89
|
+
return 0
|
|
90
|
+
fi
|
|
91
|
+
echo "Installing with npm instead: macOS may stop that reaching your other machines. Run this again later for the app."
|
|
92
|
+
return 1
|
|
93
|
+
}
|
|
94
|
+
# the command is natter; nattermatic and agentlink (its name before the rename) run it too
|
|
95
|
+
CMDS="natter nattermatic agentlink"
|
|
96
|
+
link_app() {
|
|
97
|
+
mkdir -p "$AL/bin"
|
|
98
|
+
for c in $CMDS; do rm -f "$AL/bin/$c"; ln -s "$APP_EXE" "$AL/bin/$c"; done
|
|
99
|
+
}
|
|
100
|
+
install_app() {
|
|
101
|
+
[ "$PLATFORM" = Darwin-arm64 ] && [ "${NATTERMATIC_APP:-1}" != 0 ] || return 1
|
|
102
|
+
if [ -n "${NATTERMATIC_APP_ZIP:-}" ]; then
|
|
103
|
+
# a local build: asked for by name, so failing to read it is an error, not a reason to use npm
|
|
104
|
+
cp "$NATTERMATIC_APP_ZIP" "$TMP/app.zip" ||
|
|
105
|
+
{ echo "nattermatic: cannot read $NATTERMATIC_APP_ZIP (on macOS, the terminal may not be allowed into that folder: move the file elsewhere, e.g. your home folder) — aborting"; exit 1; }
|
|
106
|
+
elif [ -n "${NATTERMATIC_APP_URL:-}" ]; then
|
|
107
|
+
echo "Fetching nattermatic.app from the inviting machine..."
|
|
108
|
+
if ! fetch_invite "$NATTERMATIC_APP_URL" "$TMP/app.zip"; then
|
|
109
|
+
no_app "the inviting machine didn't send it" && link_app && return 0
|
|
110
|
+
return 1
|
|
111
|
+
fi
|
|
112
|
+
else
|
|
113
|
+
if ! curl -fsSL "$RELEASES/nattermatic-macos-arm64.zip" -o "$TMP/app.zip" ||
|
|
114
|
+
! curl -fsSL "$RELEASES/nattermatic-macos-arm64.zip.sha256" -o "$TMP/app.sha256"; then
|
|
115
|
+
no_app "from the latest release" && link_app && return 0
|
|
116
|
+
return 1
|
|
117
|
+
fi
|
|
118
|
+
[ "$(awk '{print $1}' "$TMP/app.sha256")" = "$(shasum -a 256 "$TMP/app.zip" | awk '{print $1}')" ] ||
|
|
119
|
+
{ echo "nattermatic: the app download failed its checksum — aborting"; exit 1; }
|
|
120
|
+
fi
|
|
121
|
+
# a damaged or foreign app is an error, not a reason to fall back
|
|
122
|
+
mkdir -p "$TMP/app"
|
|
123
|
+
ditto -x -k "$TMP/app.zip" "$TMP/app" || { echo "nattermatic: the app archive is damaged — aborting"; exit 1; }
|
|
124
|
+
A="$TMP/app/nattermatic.app"
|
|
125
|
+
# the requirement checks the certificate Apple issued: the team a signature merely states can be anything
|
|
126
|
+
codesign --verify --strict -R="anchor apple generic and certificate leaf[subject.OU] = \"$TEAM\"" "$A" 2>/dev/null ||
|
|
127
|
+
{ echo "nattermatic: the app isn't validly signed by nattermatic's team ($TEAM) — aborting"; exit 1; }
|
|
128
|
+
mkdir -p "$APPS"
|
|
129
|
+
# beside the old app first, then swapped by renaming: whatever fails, a whole app is left in place.
|
|
130
|
+
# (mv into a directory that's still there would put it inside, so nothing here may be left over.)
|
|
131
|
+
NEW="$APPS/.nattermatic.app.new"
|
|
132
|
+
OLD="$APPS/.nattermatic.app.old"
|
|
133
|
+
rm -rf "$NEW" "$OLD"
|
|
134
|
+
if [ -e "$NEW" ] || [ -e "$OLD" ]; then echo "nattermatic: could not clear $NEW and $OLD — aborting"; exit 1; fi
|
|
135
|
+
ditto "$A" "$NEW" || { echo "nattermatic: could not copy the app to $APPS — aborting"; exit 1; }
|
|
136
|
+
if [ -e "$APPS/nattermatic.app" ]; then
|
|
137
|
+
mv "$APPS/nattermatic.app" "$OLD" || { echo "nattermatic: could not replace the app in $APPS — aborting"; exit 1; }
|
|
138
|
+
fi
|
|
139
|
+
if ! mv "$NEW" "$APPS/nattermatic.app"; then
|
|
140
|
+
[ ! -e "$OLD" ] || mv "$OLD" "$APPS/nattermatic.app"
|
|
141
|
+
echo "nattermatic: could not put the app in $APPS — aborting"
|
|
142
|
+
exit 1
|
|
143
|
+
fi
|
|
144
|
+
rm -rf "$OLD" || true
|
|
145
|
+
link_app
|
|
146
|
+
echo "Installed nattermatic.app in $APPS"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if install_app; then
|
|
150
|
+
NATTERMATIC_BIN_DIR="$AL/bin" "$AL/bin/natter" setup "$@"
|
|
151
|
+
exit
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
node_ok() {
|
|
155
|
+
[ "${NATTERMATIC_PRIVATE_NODE:-}" != 1 ] &&
|
|
156
|
+
command -v node >/dev/null 2>&1 &&
|
|
157
|
+
node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>23||(a===23&&b>=4)||(a===22&&b>=13)?0:1)' 2>/dev/null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
PRIV="$AL/node"
|
|
161
|
+
USE_PRIV=0
|
|
162
|
+
if node_ok; then
|
|
163
|
+
:
|
|
164
|
+
elif [ -x "$PRIV/bin/node" ] && "$PRIV/bin/node" -e 0 2>/dev/null; then
|
|
165
|
+
export PATH="$PRIV/bin:$PATH"; USE_PRIV=1
|
|
166
|
+
else
|
|
167
|
+
case "$PLATFORM" in
|
|
168
|
+
Darwin-arm64) P=darwin-arm64 ;; Darwin-x86_64) P=darwin-x64 ;;
|
|
169
|
+
Linux-x86_64) P=linux-x64 ;; Linux-aarch64|Linux-arm64) P=linux-arm64 ;;
|
|
170
|
+
*) echo "nattermatic: no prebuilt Node.js for $(uname -sm); install Node 22.13+ and run this again."; exit 1 ;;
|
|
171
|
+
esac
|
|
172
|
+
# nodejs.org's Linux builds need glibc
|
|
173
|
+
if [ "$(uname -s)" = Linux ] && ls /lib/ld-musl-* >/dev/null 2>&1; then
|
|
174
|
+
echo "nattermatic: no prebuilt Node.js for this Linux (it uses musl): install Node 22.13+ with your package manager (on Alpine: apk add nodejs npm) and run this again."
|
|
175
|
+
exit 1
|
|
176
|
+
fi
|
|
177
|
+
echo "Installing a private Node.js runtime in $PRIV (your system Node, if any, is untouched)..."
|
|
178
|
+
DIST=https://nodejs.org/dist/latest-v24.x
|
|
179
|
+
curl -fsSL "$DIST/SHASUMS256.txt" -o "$TMP/SUMS"
|
|
180
|
+
FILE=$(awk '{print $2}' "$TMP/SUMS" | grep -E "^node-v[0-9.]+-$P\.tar\.gz$" | head -1)
|
|
181
|
+
[ -n "$FILE" ] || { echo "nattermatic: could not find a Node.js download for $P"; exit 1; }
|
|
182
|
+
curl -fsSL "$DIST/$FILE" -o "$TMP/$FILE"
|
|
183
|
+
WANT=$(awk -v f="$FILE" '$2==f {print $1}' "$TMP/SUMS")
|
|
184
|
+
GOT=$( (shasum -a 256 "$TMP/$FILE" 2>/dev/null || sha256sum "$TMP/$FILE") | awk '{print $1}')
|
|
185
|
+
[ "$WANT" = "$GOT" ] || { echo "nattermatic: Node.js download failed its checksum — aborting"; exit 1; }
|
|
186
|
+
rm -rf "$PRIV"; mkdir -p "$PRIV"
|
|
187
|
+
tar xzf "$TMP/$FILE" -C "$PRIV" --strip-components=1
|
|
188
|
+
export PATH="$PRIV/bin:$PATH"; USE_PRIV=1
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
# joining via an invite: the package comes from the inviting machine
|
|
192
|
+
case "$PKG" in
|
|
193
|
+
https://*) fetch_invite "$PKG" "$TMP/nattermatic.tgz"; PKG="$TMP/nattermatic.tgz" ;;
|
|
194
|
+
esac
|
|
195
|
+
|
|
196
|
+
# npm install into a prefix, showing npm's own error output if it fails
|
|
197
|
+
npm_install() {
|
|
198
|
+
npm install -g --no-fund --no-audit --loglevel=error "$@" "$PKG" >"$TMP/npm.log" 2>&1 && return 0
|
|
199
|
+
return 1
|
|
200
|
+
}
|
|
201
|
+
npm_failed() { cat "$TMP/npm.log" >&2; echo "nattermatic: installing $PKG with npm failed (npm's output is above)" >&2; exit 1; }
|
|
202
|
+
|
|
203
|
+
echo "Installing nattermatic..."
|
|
204
|
+
if [ "$USE_PRIV" = 1 ]; then
|
|
205
|
+
# Keep the private runtime off the user's PATH (it would shadow their own node/npm): install the
|
|
206
|
+
# package in its own prefix and put a single wrapper, run by the private node, in $AL/bin.
|
|
207
|
+
npm_install --prefix "$AL/pkg" || npm_failed
|
|
208
|
+
JS=$(node -e 'console.log(require("fs").realpathSync(process.argv[1]))' "$AL/pkg/bin/natter")
|
|
209
|
+
BINDIR="$AL/bin"
|
|
210
|
+
mkdir -p "$BINDIR"
|
|
211
|
+
q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
|
212
|
+
for c in $CMDS; do
|
|
213
|
+
rm -f "$BINDIR/$c" # it may be the link to an earlier app: writing through it would replace the app
|
|
214
|
+
# shellcheck disable=SC2016 # "$@" is meant literally: it's the wrapper's own arguments
|
|
215
|
+
printf '#!/bin/sh\nexec %s --no-warnings %s "$@"\n' "$(q "$PRIV/bin/node")" "$(q "$JS")" > "$BINDIR/$c"
|
|
216
|
+
chmod 755 "$BINDIR/$c"
|
|
217
|
+
done
|
|
218
|
+
elif { npm uninstall -g --loglevel=error @tombaldwin/agentlink >/dev/null 2>&1; npm_install; }; then # agentlink: its name before the rename
|
|
219
|
+
BINDIR="$(npm prefix -g)/bin"
|
|
220
|
+
elif grep -qE 'EACCES|EPERM' "$TMP/npm.log"; then
|
|
221
|
+
echo "(global npm install not permitted; installing to ~/.local)"
|
|
222
|
+
npm uninstall -g --prefix "$HOME/.local" --loglevel=error @tombaldwin/agentlink >/dev/null 2>&1 || true
|
|
223
|
+
npm_install --prefix "$HOME/.local" || npm_failed
|
|
224
|
+
BINDIR="$HOME/.local/bin"
|
|
225
|
+
else
|
|
226
|
+
npm_failed
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
NATTERMATIC_BIN_DIR="$BINDIR" "$BINDIR/natter" setup "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nattermatic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Let AI agents on different Claude accounts and machines natter to each other — incoming messages wake idle Claude Code and Codex sessions",
|
|
5
|
+
"mcpName": "io.github.tombaldwin/nattermatic",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"claude",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"mcp",
|
|
10
|
+
"agents",
|
|
11
|
+
"multi-agent",
|
|
12
|
+
"channels",
|
|
13
|
+
"messaging",
|
|
14
|
+
"lan"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"natter": "bin/natter.js",
|
|
19
|
+
"nattermatic": "bin/natter.js",
|
|
20
|
+
"agentlink": "bin/natter.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src",
|
|
25
|
+
"install.sh",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"SECURITY.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": "^22.13.0 || >=23.4.0"
|
|
32
|
+
},
|
|
33
|
+
"os": [
|
|
34
|
+
"darwin",
|
|
35
|
+
"linux"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/tombaldwin/nattermatic.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://nattermatic.com",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/tombaldwin/nattermatic/issues"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
47
|
+
"test:unit": "node --no-warnings --test test/unit/*.test.js",
|
|
48
|
+
"test:e2e": "node --no-warnings test/e2e.js",
|
|
49
|
+
"typecheck": "tsc -p .",
|
|
50
|
+
"build:app": "node scripts/build-app.mjs"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.32.2"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.20.5",
|
|
57
|
+
"esbuild": "^0.28.2",
|
|
58
|
+
"postject": "^1.0.0-alpha.6",
|
|
59
|
+
"typescript": "^7.0.2"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"author": "Tom Baldwin",
|
|
65
|
+
"license": "MIT"
|
|
66
|
+
}
|
package/src/cert.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Machine identity: an ECDSA P-256 key pair and a minimal self-signed X.509 certificate for it.
|
|
2
|
+
// Node can sign but not build certificates, so this encodes the one fixed certificate shape we need.
|
|
3
|
+
// P-256 rather than Ed25519: stock macOS curl (LibreSSL) can't do TLS with Ed25519 certificates.
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
/** @param {number} n */
|
|
7
|
+
const derLen = n => {
|
|
8
|
+
if (n < 0x80) return Buffer.from([n])
|
|
9
|
+
/** @type {number[]} */
|
|
10
|
+
const b = []
|
|
11
|
+
while (n) { b.unshift(n & 0xff); n >>= 8 }
|
|
12
|
+
return Buffer.from([0x80 | b.length, ...b])
|
|
13
|
+
}
|
|
14
|
+
/** @param {number} tag @param {Buffer[]} parts */
|
|
15
|
+
const tlv = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), derLen(body.length), body]) }
|
|
16
|
+
/** @param {Buffer[]} p */
|
|
17
|
+
const seq = (...p) => tlv(0x30, ...p)
|
|
18
|
+
/** @param {Buffer[]} p */
|
|
19
|
+
const set = (...p) => tlv(0x31, ...p)
|
|
20
|
+
/** @param {string} s */
|
|
21
|
+
function oid(s) {
|
|
22
|
+
const a = s.split('.').map(Number), out = [a[0] * 40 + a[1]]
|
|
23
|
+
for (const v of a.slice(2)) {
|
|
24
|
+
const b = [v & 0x7f]
|
|
25
|
+
for (let x = v >>> 7; x; x >>>= 7) b.unshift(0x80 | (x & 0x7f))
|
|
26
|
+
out.push(...b)
|
|
27
|
+
}
|
|
28
|
+
return tlv(0x06, Buffer.from(out))
|
|
29
|
+
}
|
|
30
|
+
/** @param {Buffer} buf */
|
|
31
|
+
const int = buf => tlv(0x02, buf[0] & 0x80 ? Buffer.concat([Buffer.from([0]), buf]) : buf)
|
|
32
|
+
/** @param {Date} d */
|
|
33
|
+
const utcTime = d => tlv(0x17, Buffer.from(d.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'))
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A self-signed certificate for `privateKey`. Anyone can open a TLS connection to a node, so the
|
|
37
|
+
* certificate must not tell them the machine's name.
|
|
38
|
+
* @param {crypto.KeyObject} privateKey
|
|
39
|
+
* @returns {string} PEM
|
|
40
|
+
*/
|
|
41
|
+
export function selfSignedCert(privateKey) {
|
|
42
|
+
const alg = seq(oid('1.2.840.10045.4.3.2')) // ecdsa-with-SHA256
|
|
43
|
+
const spki = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
|
|
44
|
+
// unique per key (TLS picks a trusted certificate by subject when several are trusted at once),
|
|
45
|
+
// derived from the key, never from the machine's name
|
|
46
|
+
const cn = `agentlink-${crypto.createHash('sha256').update(spki).digest('hex').slice(0, 16)}`
|
|
47
|
+
const name = seq(set(seq(oid('2.5.4.3'), tlv(0x0c, Buffer.from(cn)))))
|
|
48
|
+
const serial = crypto.randomBytes(16)
|
|
49
|
+
serial[0] = (serial[0] & 0x7f) | 0x01
|
|
50
|
+
const basicConstraints = seq(oid('2.5.29.19'), tlv(0x01, Buffer.from([0xff])), tlv(0x04, seq()))
|
|
51
|
+
const tbs = seq(
|
|
52
|
+
tlv(0xa0, int(Buffer.from([2]))), // v3
|
|
53
|
+
int(serial), alg, name,
|
|
54
|
+
seq(utcTime(new Date(Date.now() - 86400_000)), tlv(0x18, Buffer.from('99991231235959Z'))), // no expiry
|
|
55
|
+
name, spki,
|
|
56
|
+
tlv(0xa3, seq(basicConstraints)))
|
|
57
|
+
const sig = crypto.sign('sha256', tbs, privateKey)
|
|
58
|
+
const der = seq(tbs, alg, tlv(0x03, Buffer.from([0]), sig))
|
|
59
|
+
return `-----BEGIN CERTIFICATE-----\n${(der.toString('base64').match(/.{1,64}/g) || []).join('\n')}\n-----END CERTIFICATE-----\n`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A machine's identity: sha256 of its public key (SPKI DER), base64. The same format curl's
|
|
64
|
+
* --pinnedpubkey takes ("sha256//<fp>").
|
|
65
|
+
* @param {crypto.KeyObject | crypto.X509Certificate | string} k a key, a certificate, or a PEM of either
|
|
66
|
+
*/
|
|
67
|
+
export function fingerprint(k) {
|
|
68
|
+
let pub
|
|
69
|
+
if (k instanceof crypto.X509Certificate) pub = k.publicKey
|
|
70
|
+
else if (typeof k === 'string' && k.includes('CERTIFICATE')) pub = new crypto.X509Certificate(k).publicKey
|
|
71
|
+
else pub = crypto.createPublicKey(k)
|
|
72
|
+
return crypto.createHash('sha256').update(pub.export({ type: 'spki', format: 'der' })).digest('base64')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A short form of a fingerprint for display. @param {string} fp */
|
|
76
|
+
export const shortFp = fp => fp.replace(/[^A-Za-z0-9]/g, '').slice(0, 10)
|
|
77
|
+
|
|
78
|
+
/** @returns {import('./types.js').Identity} */
|
|
79
|
+
export function newIdentity() {
|
|
80
|
+
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
81
|
+
const cert = selfSignedCert(privateKey)
|
|
82
|
+
return { key: /** @type {string} */ (privateKey.export({ type: 'pkcs8', format: 'pem' })), cert, fp: fingerprint(privateKey) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {string} pem */
|
|
86
|
+
export const certDer = pem => new crypto.X509Certificate(pem).raw.toString('base64')
|