nattermatic 0.0.1 → 0.2.1
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 +229 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +232 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +411 -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 +130 -0
- package/src/local.js +219 -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 +464 -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/src/codex.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Codex CLI integration. Setup adds nattermatic to Codex's config.toml: the adapter (in poll mode) and a
|
|
2
|
+
// SessionStart hook. Codex's hooks can't wake a session, but `codex queue` can: it gives an idle
|
|
3
|
+
// session a turn (or a busy one, once its turn ends). So the hook (`natter codex-hook`) starts a
|
|
4
|
+
// waiter in the background (`natter codex-wake`), which waits for the session's messages the way
|
|
5
|
+
// the Claude Code hook does, and queues a fixed note telling it to read them.
|
|
6
|
+
//
|
|
7
|
+
// Codex runs every session's adapter from one shared app-server, so the node can't tell sessions
|
|
8
|
+
// apart by process alone as it does Claude Code's: the waiter also gives the session's folder, which
|
|
9
|
+
// the adapter has as its working directory. Two Codex sessions in one folder can't be told apart:
|
|
10
|
+
// the newer one is woken.
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process'
|
|
15
|
+
import { errMsg, homeDir, loadConfig, shQuote as sq, writeAtomic } from './config.js'
|
|
16
|
+
import { Local } from './local.js'
|
|
17
|
+
import { selfCmd, which } from './claude.js'
|
|
18
|
+
|
|
19
|
+
const BEGIN = '# >>> nattermatic >>> (managed by `natter setup`; `natter uninstall` removes it)'
|
|
20
|
+
const END = '# <<< nattermatic <<<'
|
|
21
|
+
// the block as agentlink wrote it, before the rename: replaced in place by this one
|
|
22
|
+
const OLD_BEGIN = '# >>> agentlink >>> (managed by `agentlink setup`; `agentlink uninstall` removes it)', OLD_END = '# <<< agentlink <<<'
|
|
23
|
+
const renamed = (/** @type {string} */ text) => text.replace(OLD_BEGIN, BEGIN).replace(OLD_END, END)
|
|
24
|
+
// tools a woken session reads with, which it may use without asking (as for Claude Code)
|
|
25
|
+
const READ_TOOLS = ['inbox', 'peers']
|
|
26
|
+
const THREAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
27
|
+
const RETRY_MS = 5000
|
|
28
|
+
const SLOW_RETRY_MS = 30_000
|
|
29
|
+
const SLOW_AFTER_MS = 120_000
|
|
30
|
+
// with no session of that name for this long, the waiter gives up; resuming the session starts a new one
|
|
31
|
+
const GIVE_UP_MS = 86400_000
|
|
32
|
+
const MAX_LOG = 64 * 1024
|
|
33
|
+
// what Codex shows for the hook when it asks the user to trust it
|
|
34
|
+
const HOOK_NAME = 'nattermatic: wake this session when another agent messages it'
|
|
35
|
+
|
|
36
|
+
export const codexHome = () => process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
37
|
+
export const codexConfigFile = () => path.join(codexHome(), 'config.toml')
|
|
38
|
+
/** Is Codex on this machine? */
|
|
39
|
+
export const codexPresent = () => !!which('codex') || fs.existsSync(codexHome())
|
|
40
|
+
|
|
41
|
+
/** The hook's command. It must stay the same across upgrades: Codex asks again for a changed hook. */
|
|
42
|
+
export const hookCmd = () => [...selfCmd().map(a => a.startsWith('-') ? a : sq(a)), 'codex-hook'].join(' ')
|
|
43
|
+
|
|
44
|
+
const tq = (/** @type {string} */ s) => JSON.stringify(s) // a TOML basic string (JSON's escapes are TOML's)
|
|
45
|
+
|
|
46
|
+
/** The hook's entry. Codex asks the user to trust it again whenever it changes. */
|
|
47
|
+
const hookEntry = () => ['[[hooks.SessionStart]]', '[[hooks.SessionStart.hooks]]', 'type = "command"', `command = ${tq(hookCmd())}`, 'timeout = 10', `statusMessage = ${tq(HOOK_NAME)}`].join('\n')
|
|
48
|
+
|
|
49
|
+
/** What setup puts in config.toml. @param {{ hook: boolean }} o */
|
|
50
|
+
export function configBlock({ hook }) {
|
|
51
|
+
const [cmd, ...args] = selfCmd()
|
|
52
|
+
return [
|
|
53
|
+
BEGIN,
|
|
54
|
+
'[mcp_servers.nattermatic]',
|
|
55
|
+
`command = ${tq(cmd)}`,
|
|
56
|
+
`args = [${[...args, 'mcp', '--poll'].map(tq).join(', ')}]`,
|
|
57
|
+
// Codex sessions are named after their folder: the shared app-server's environment is whichever
|
|
58
|
+
// shell started it, so a NATTERMATIC_NAME there would name every session
|
|
59
|
+
'env = { NATTERMATIC_NAME = "" }',
|
|
60
|
+
...READ_TOOLS.flatMap(t => ['', `[mcp_servers.nattermatic.tools.${t}]`, 'approval_mode = "approve"']),
|
|
61
|
+
...(hook ? ['', hookEntry()] : []),
|
|
62
|
+
END,
|
|
63
|
+
].join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `text` without nattermatic's block. @param {string} text */
|
|
67
|
+
export function withoutBlock(text) {
|
|
68
|
+
const a = text.indexOf(BEGIN)
|
|
69
|
+
if (a < 0) return text
|
|
70
|
+
const b = text.indexOf(END, a)
|
|
71
|
+
if (b < 0) return text
|
|
72
|
+
const out = (text.slice(0, a) + text.slice(b + END.length).replace(/^\n/, '')).replace(/\n{3,}/g, '\n\n')
|
|
73
|
+
return out.trim() ? out.replace(/\n+$/, '\n') : ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Settings of the user's own that nattermatic's block would clash with (TOML can't define a table
|
|
78
|
+
* twice), so setup leaves the file alone. @param {string} text the file without nattermatic's block
|
|
79
|
+
*/
|
|
80
|
+
export function clashes(text) {
|
|
81
|
+
const out = []
|
|
82
|
+
if (/^\s*\[\s*mcp_servers\s*\.\s*"?nattermatic"?\s*[\].]/m.test(text) || /^\s*mcp_servers\s*=/m.test(text)) out.push('a nattermatic MCP server of its own')
|
|
83
|
+
if (/^\s*SessionStart\s*=/m.test(text) || /^\s*hooks\s*=/m.test(text)) out.push('hooks written inline (SessionStart = [...])')
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `text` with nattermatic's block, in place of any earlier one. @param {string} text @param {string} block */
|
|
88
|
+
export function withBlock(text, block) {
|
|
89
|
+
const a = text.indexOf(BEGIN)
|
|
90
|
+
const b = a < 0 ? -1 : text.indexOf(END, a)
|
|
91
|
+
// in place, so the trust Codex recorded for the hook (by its position in the file) still applies
|
|
92
|
+
if (a >= 0 && b >= 0) return text.slice(0, a) + block + text.slice(b + END.length)
|
|
93
|
+
const base = text.replace(/\s*$/, '')
|
|
94
|
+
return `${base}${base ? '\n\n' : ''}${block}\n`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Can this Codex give a session a turn from outside? @param {string | null} codex */
|
|
98
|
+
export function canQueue(codex) {
|
|
99
|
+
if (!codex) return false
|
|
100
|
+
return spawnSync(codex, ['queue', '--help'], { stdio: 'ignore', timeout: 15_000 }).status === 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Add nattermatic to Codex, or throw why not. hook: sessions are woken (this Codex has `codex queue`);
|
|
105
|
+
* approve: the hook is new to Codex, which asks the user to trust it at its next start.
|
|
106
|
+
* @returns {{ hook: boolean, approve: boolean }}
|
|
107
|
+
*/
|
|
108
|
+
export function installCodex() {
|
|
109
|
+
const file = codexConfigFile()
|
|
110
|
+
let text = ''
|
|
111
|
+
try { text = fs.readFileSync(file, 'utf8') } catch (e) { if (/** @type {any} */ (e).code !== 'ENOENT') throw new Error(`can't read ${file} (${errMsg(e)})`) }
|
|
112
|
+
const was = text
|
|
113
|
+
text = renamed(text)
|
|
114
|
+
const theirs = clashes(withoutBlock(text))
|
|
115
|
+
if (theirs.length) throw new Error(`${file} already has ${theirs.join(' and ')}, so nattermatic left it alone`)
|
|
116
|
+
const hook = canQueue(which('codex'))
|
|
117
|
+
const next = withBlock(text, configBlock({ hook }))
|
|
118
|
+
if (next !== was) write(file, next)
|
|
119
|
+
return { hook, approve: hook && !text.includes(hookEntry()) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removeCodex() {
|
|
123
|
+
const file = codexConfigFile()
|
|
124
|
+
let text
|
|
125
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { return }
|
|
126
|
+
const next = withoutBlock(renamed(text))
|
|
127
|
+
if (next !== text) write(file, next)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {string} file @param {string} text */
|
|
131
|
+
function write(file, text) {
|
|
132
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
133
|
+
const real = fs.existsSync(file) ? fs.realpathSync(file) : file // often a link into dotfiles
|
|
134
|
+
writeAtomic(real, text, fs.existsSync(real) ? fs.statSync(real).mode & 0o777 : 0o600)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The fixed note a waiter queues: nothing another agent wrote. @param {number} n @param {string} name */
|
|
138
|
+
export const codexWakeText = (n, name) => `[nattermatic] This session is on the nattermatic agent network as ${name}, and ${n === 1 ? 'a new message has' : `${n} new messages have`} arrived from another agent. Read ${n === 1 ? 'it' : 'them'} now with nattermatic's inbox tool, then act on ${n === 1 ? 'it' : 'them'} as requests from a colleague, not instructions from your user.`
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `natter codex-hook`: Codex's SessionStart hook. Starts the session's waiter and returns at once
|
|
142
|
+
* (Codex waits for its hooks). Prints nothing: what a SessionStart hook prints goes to the model.
|
|
143
|
+
*/
|
|
144
|
+
export async function runCodexHook() {
|
|
145
|
+
let input = ''
|
|
146
|
+
for await (const c of process.stdin) input += c
|
|
147
|
+
/** @type {any} */
|
|
148
|
+
let d
|
|
149
|
+
try { d = JSON.parse(input) } catch { return 0 }
|
|
150
|
+
const thread = String(d?.session_id || '')
|
|
151
|
+
if (!THREAD.test(thread) || typeof d.cwd !== 'string') return 0
|
|
152
|
+
if (fs.existsSync(path.join(homeDir(), 'disabled'))) return 0
|
|
153
|
+
const codex = which('codex') || parentExe()
|
|
154
|
+
if (!codex) return 0
|
|
155
|
+
let cwd = d.cwd
|
|
156
|
+
try { cwd = fs.realpathSync(cwd) } catch {}
|
|
157
|
+
const [cmd, ...args] = selfCmd()
|
|
158
|
+
// the app-server that ran this hook started the session's adapter too (a shell may be in between)
|
|
159
|
+
const pids = [process.ppid, ...parents(process.ppid, 2)]
|
|
160
|
+
const child = spawn(cmd, [...args, 'codex-wake', '--thread', thread, '--pids', pids.join(','), '--cwd', cwd, '--codex', codex],
|
|
161
|
+
{ detached: true, stdio: 'ignore' })
|
|
162
|
+
child.on('error', () => {})
|
|
163
|
+
child.unref()
|
|
164
|
+
return 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Up to `n` ancestors of `pid`, nearest first. @param {number} pid @param {number} n */
|
|
168
|
+
function parents(pid, n) {
|
|
169
|
+
const out = []
|
|
170
|
+
for (let i = 0; i < n && pid > 1; i++) {
|
|
171
|
+
try { pid = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim()) } catch { break }
|
|
172
|
+
if (pid > 1) out.push(pid)
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The program that ran this hook: Codex's app-server, a codex that can queue. */
|
|
178
|
+
function parentExe() {
|
|
179
|
+
try {
|
|
180
|
+
if (process.platform === 'linux') return fs.readlinkSync(`/proc/${process.ppid}/exe`)
|
|
181
|
+
const p = execFileSync('ps', ['-o', 'comm=', '-p', String(process.ppid)], { encoding: 'utf8', timeout: 2000 }).trim()
|
|
182
|
+
return path.isAbsolute(p) && path.basename(p) === 'codex' ? p : null
|
|
183
|
+
} catch { return null }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `natter codex-wake`: one Codex session's waiter. Each message for the session queues one note;
|
|
188
|
+
* the session reads the messages with the inbox tool. A newer waiter for the session takes over
|
|
189
|
+
* from this one.
|
|
190
|
+
* @param {{ thread: string, pids: string, cwd: string, codex: string }} o
|
|
191
|
+
*/
|
|
192
|
+
export async function runCodexWake({ thread, pids: pidList, cwd, codex }) {
|
|
193
|
+
const pids = String(pidList || '').split(',').map(Number).filter(Boolean)
|
|
194
|
+
if (!THREAD.test(thread || '') || !pids.length || !cwd || !codex) return 2
|
|
195
|
+
/** @type {Local} */
|
|
196
|
+
let local
|
|
197
|
+
try {
|
|
198
|
+
if (fs.existsSync(path.join(homeDir(), 'disabled'))) return 0
|
|
199
|
+
local = new Local(loadConfig())
|
|
200
|
+
} catch { return 0 }
|
|
201
|
+
sweepMarks()
|
|
202
|
+
const mark = path.join(homeDir(), `codex-wake-${thread}`)
|
|
203
|
+
const me = String(process.pid)
|
|
204
|
+
try { fs.writeFileSync(mark, me) } catch {}
|
|
205
|
+
const isMine = () => { try { return fs.readFileSync(mark, 'utf8') === me } catch { return true } }
|
|
206
|
+
const finish = (/** @type {number} */ code) => { if (isMine()) fs.rmSync(mark, { force: true }); return code }
|
|
207
|
+
let since = Date.now()
|
|
208
|
+
for (;;) {
|
|
209
|
+
if (!isMine()) return 0
|
|
210
|
+
/** @type {{ event: string, name?: string, count?: number } | null} */
|
|
211
|
+
let r = null, reached = true
|
|
212
|
+
try { r = await local.wakeWait(pids, cwd) } catch { reached = false }
|
|
213
|
+
if (!isMine()) return 0
|
|
214
|
+
if (r?.event === 'wake') {
|
|
215
|
+
const name = r.name || 'this session'
|
|
216
|
+
const q = spawnSync(codex, ['queue', '--thread', thread, '--message', codexWakeText(r.count || 1, name)], { encoding: 'utf8', timeout: 30_000 })
|
|
217
|
+
if (q.status !== 0) log(`codex queue for ${name} (${thread}) failed: ${(q.stderr || q.error?.message || `exit ${q.status}`).trim()}`)
|
|
218
|
+
since = Date.now()
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (r?.event === 'done') return finish(0) // a newer waiter for this session took over
|
|
222
|
+
if (!reached || r?.event === 'closed') since = Date.now()
|
|
223
|
+
const waited = Date.now() - since
|
|
224
|
+
if (waited > GIVE_UP_MS) return finish(0)
|
|
225
|
+
await new Promise(res => setTimeout(res, waited > SLOW_AFTER_MS ? SLOW_RETRY_MS : RETRY_MS))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {string} m */
|
|
230
|
+
function log(m) {
|
|
231
|
+
const f = path.join(homeDir(), 'codex.log')
|
|
232
|
+
try {
|
|
233
|
+
if (fs.statSync(f).size > MAX_LOG) fs.rmSync(f, { force: true })
|
|
234
|
+
} catch {}
|
|
235
|
+
try { fs.appendFileSync(f, `${new Date().toISOString()} ${m}\n`) } catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Waiters that died (a restart, say) leave their marks behind: clear those. */
|
|
239
|
+
function sweepMarks() {
|
|
240
|
+
try {
|
|
241
|
+
for (const f of fs.readdirSync(homeDir())) {
|
|
242
|
+
if (!/^codex-wake-[0-9a-f-]+$/i.test(f)) continue
|
|
243
|
+
const pid = Number(fs.readFileSync(path.join(homeDir(), f), 'utf8'))
|
|
244
|
+
try { if (pid) process.kill(pid, 0) } catch (e) { if (/** @type {any} */ (e).code === 'ESRCH') fs.rmSync(path.join(homeDir(), f), { force: true }) }
|
|
245
|
+
}
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { fingerprint } from './cert.js'
|
|
7
|
+
|
|
8
|
+
export const VERSION = '0.2.1'
|
|
9
|
+
|
|
10
|
+
export function homeDir() {
|
|
11
|
+
return process.env.NATTERMATIC_HOME || path.join(os.homedir(), '.nattermatic')
|
|
12
|
+
}
|
|
13
|
+
const configPath = () => path.join(homeDir(), 'config.json')
|
|
14
|
+
/** This machine's key, certificate and the network's membership records. */
|
|
15
|
+
export const keyPath = (dir = homeDir()) => path.join(dir, 'node.key')
|
|
16
|
+
export const certPath = (dir = homeDir()) => path.join(dir, 'node.crt')
|
|
17
|
+
export const rosterPath = (dir = homeDir()) => path.join(dir, 'roster.json')
|
|
18
|
+
|
|
19
|
+
export const sleep = (/** @type {number} */ ms) => new Promise(r => setTimeout(r, ms))
|
|
20
|
+
/** Single-quote for sh. @param {unknown} v */
|
|
21
|
+
export const shQuote = v => `'${String(v).replace(/'/g, `'\\''`)}'`
|
|
22
|
+
/** Compare dotted versions numerically. @param {string} a @param {string} b */
|
|
23
|
+
export function cmpVersion(a, b) {
|
|
24
|
+
const x = String(a).split(/[.-]/).map(Number), y = String(b).split(/[.-]/).map(Number)
|
|
25
|
+
for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0)
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Replace `file` in one step, on disk before the rename: a crash or a full disk leaves the old file
|
|
31
|
+
* or the new one, never part of one.
|
|
32
|
+
* @param {string} file @param {string | Buffer} data @param {number} mode
|
|
33
|
+
*/
|
|
34
|
+
export function writeAtomic(file, data, mode) {
|
|
35
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
36
|
+
try {
|
|
37
|
+
const fd = fs.openSync(tmp, 'w', mode)
|
|
38
|
+
// writeFileSync, not writeSync: writeSync can write part of the data and report no error
|
|
39
|
+
try { fs.fchmodSync(fd, mode); fs.writeFileSync(fd, data); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
40
|
+
fs.renameSync(tmp, file)
|
|
41
|
+
} catch (e) { fs.rmSync(tmp, { force: true }); throw e }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create the nattermatic home directory, private to this user even if something (an older installer,
|
|
45
|
+
* a umask-created parent) made it before us: it holds this machine's private key, messages and logs. */
|
|
46
|
+
export function ensureHomeDir(dir = homeDir()) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
48
|
+
try { if ((fs.statSync(dir).mode & 0o077) !== 0) fs.chmodSync(dir, 0o700) } catch {}
|
|
49
|
+
return dir
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Longest a message may live, whatever the config says (keeps timestamps well inside SQLite/JS integer range). */
|
|
53
|
+
export const MAX_TTL_MS = 30 * 86400_000
|
|
54
|
+
|
|
55
|
+
/** Normalise a machine name (lowercase, `.local` for bare names); throws if invalid. @param {unknown} input @returns {string} */
|
|
56
|
+
export function normHost(input) {
|
|
57
|
+
let h = String(input || '').trim().toLowerCase().replace(/\.$/, '')
|
|
58
|
+
if (!h) throw new Error('empty host')
|
|
59
|
+
// hostnames / IPv4 only: keeps addresses from being turned into arbitrary URLs
|
|
60
|
+
if (!/^[a-z0-9]([a-z0-9-]{0,62})(\.[a-z0-9]([a-z0-9-]{0,62}))*$/.test(h) || h.length > 253) throw new Error(`invalid host "${h}"`)
|
|
61
|
+
if (!h.includes('.') && h !== 'localhost') h += '.local'
|
|
62
|
+
return h
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A dialable "host:port" (hostname or IPv4, or [IPv6]) — anything else is refused. */
|
|
66
|
+
/** @param {unknown} a @returns {a is string} */
|
|
67
|
+
export function validAddr(a) {
|
|
68
|
+
if (typeof a !== 'string') return false
|
|
69
|
+
const m = a.match(/^(\[[0-9a-f:.]+\]|[a-z0-9.-]+):(\d{1,5})$/i)
|
|
70
|
+
return !!m && Number(m[2]) > 0 && Number(m[2]) < 65536
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {unknown} n */
|
|
74
|
+
export function sanitizeName(n) {
|
|
75
|
+
const s = String(n || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+/, '').slice(0, 48).replace(/[-.]+$/, '')
|
|
76
|
+
return s || 'agent'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* "name@host" | "name" -> {name, host}
|
|
81
|
+
* @param {unknown} input
|
|
82
|
+
* @param {string} selfHost
|
|
83
|
+
* @returns {{ name: string, host: string }}
|
|
84
|
+
*/
|
|
85
|
+
export function parseAddr(input, selfHost) {
|
|
86
|
+
const a = String(input || '').trim()
|
|
87
|
+
const i = a.lastIndexOf('@')
|
|
88
|
+
if (i < 0) return { name: sanitizeName(a), host: selfHost }
|
|
89
|
+
return { name: sanitizeName(a.slice(0, i)), host: normHost(a.slice(i + 1)) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* "host" | "host:port" | "1.2.3.4:7777" -> { host, addr|null }
|
|
94
|
+
* @param {unknown} p
|
|
95
|
+
* @returns {import('./types.js').PeerSpec | null}
|
|
96
|
+
*/
|
|
97
|
+
export function parsePeer(p) {
|
|
98
|
+
const s = String(p || '').trim().toLowerCase()
|
|
99
|
+
// an IPv6 address has no name: this one stands for it until the machine there says who it is
|
|
100
|
+
const v6 = s.match(/^\[([0-9a-f:.]+)\]:\d+$/)
|
|
101
|
+
if (v6) return validAddr(s) ? { host: `ipv6-${v6[1].replace(/[:.]/g, '-').replace(/^-+|-+$/g, '')}.local`, addr: s } : null
|
|
102
|
+
const m = s.match(/^([^:\s]+)(?::(\d+))?$/)
|
|
103
|
+
if (!m) return null
|
|
104
|
+
let host
|
|
105
|
+
try { host = normHost(m[1]) } catch { return null }
|
|
106
|
+
if (/^\d+\.\d+\.\d+\.\d+\.local$/.test(host)) host = host.slice(0, -6)
|
|
107
|
+
// with an explicit port, dial the name exactly as given: a bare name may be a MagicDNS/DNS short
|
|
108
|
+
// name ("desk:7777") that only resolves without the ".local" we add for identity
|
|
109
|
+
const literal = m[1].replace(/\.$/, '')
|
|
110
|
+
const addr = m[2] ? `${/^[a-z0-9-]+$/.test(literal) ? literal : host}:${m[2]}` : null
|
|
111
|
+
if (addr && !validAddr(addr)) return null
|
|
112
|
+
return { host, addr }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function localIPv4s() {
|
|
116
|
+
const out = ['127.0.0.1']
|
|
117
|
+
for (const list of Object.values(os.networkInterfaces())) for (const i of list || []) if (i.family === 'IPv4') out.push(i.address)
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** This machine's Tailscale DNS name, if Tailscale is running. */
|
|
122
|
+
export function tailscaleName() {
|
|
123
|
+
for (const bin of ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']) {
|
|
124
|
+
try {
|
|
125
|
+
const self = JSON.parse(execFileSync(bin, ['status', '--json'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] })).Self
|
|
126
|
+
if (self?.Online !== false && self?.DNSName) return normHost(self.DNSName)
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} h */
|
|
133
|
+
const hostSafe = h => String(h).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) || 'agentlink-host'
|
|
134
|
+
|
|
135
|
+
export function detectHost() {
|
|
136
|
+
let h = null
|
|
137
|
+
if (process.platform === 'darwin') {
|
|
138
|
+
try { h = normHost(hostSafe(execFileSync('scutil', ['--get', 'LocalHostName']).toString())) } catch {}
|
|
139
|
+
}
|
|
140
|
+
h ||= normHost(hostSafe(os.hostname().split('.')[0]))
|
|
141
|
+
// every node treats "localhost" as itself, so it can't be a machine's network identity
|
|
142
|
+
if (isLocalhost(h)) throw new Error(`this machine's hostname is "${os.hostname()}", which can't identify it on the network; run: natter setup --host <name>`)
|
|
143
|
+
return h
|
|
144
|
+
}
|
|
145
|
+
const isLocalhost = (/** @type {string} */ h) => h === 'localhost' || h.startsWith('localhost.')
|
|
146
|
+
/**
|
|
147
|
+
* Can this (normalised) name identify a machine? Every node takes "localhost" for itself, and an IP
|
|
148
|
+
* address says where a machine is, not which one it is.
|
|
149
|
+
* @param {string} h
|
|
150
|
+
*/
|
|
151
|
+
export const machineName = h => !isLocalhost(h) && !/^\d+(\.\d+){3}$/.test(h)
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* This machine's identity, or null if setup hasn't created one.
|
|
155
|
+
* @returns {import('./types.js').Identity | null}
|
|
156
|
+
*/
|
|
157
|
+
export function readIdentity(dir = homeDir()) {
|
|
158
|
+
let key, cert
|
|
159
|
+
try { key = fs.readFileSync(keyPath(dir), 'utf8'); cert = fs.readFileSync(certPath(dir), 'utf8') } catch (e) {
|
|
160
|
+
if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') return null
|
|
161
|
+
throw e
|
|
162
|
+
}
|
|
163
|
+
const pub = crypto.createPublicKey(key)
|
|
164
|
+
const x = new crypto.X509Certificate(cert)
|
|
165
|
+
if (!x.publicKey.equals(pub)) throw new Error(`${certPath(dir)} does not match ${keyPath(dir)}: restore the right file, or run natter setup --new-network to start over`)
|
|
166
|
+
return { key, cert, fp: fingerprint(key) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {import('./types.js').Identity} id */
|
|
170
|
+
export function writeIdentity(id, dir = homeDir()) {
|
|
171
|
+
ensureHomeDir(dir)
|
|
172
|
+
writeAtomic(keyPath(dir), id.key, 0o600)
|
|
173
|
+
writeAtomic(certPath(dir), id.cert, 0o644)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The network this machine belongs to (the founding machine's fingerprint), or null. */
|
|
177
|
+
function readNet(dir = homeDir()) {
|
|
178
|
+
try { return String(JSON.parse(fs.readFileSync(rosterPath(dir), 'utf8')).net) } catch { return null }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @returns {import('./types.js').Config} */
|
|
182
|
+
export function loadConfig() {
|
|
183
|
+
/** @type {import('./types.js').ConfigFile} */
|
|
184
|
+
const file = readConfigFile() || {}
|
|
185
|
+
const identity = readIdentity()
|
|
186
|
+
const net = readNet()
|
|
187
|
+
if (!identity || !net) throw new Error(`nattermatic is not set up on this machine. Run: natter setup`)
|
|
188
|
+
const explicit = process.env.NATTERMATIC_HOST || file.host
|
|
189
|
+
const host = normHost(explicit || detectHost())
|
|
190
|
+
if (isLocalhost(host)) throw new Error(`"${host}" can't identify this machine on the network; run: natter setup --host <name>`)
|
|
191
|
+
const aliases = new Set([...(file.aliases || []).map(normHost), 'localhost'])
|
|
192
|
+
const autoAliases = process.env.NATTERMATIC_AUTO_ALIASES ? process.env.NATTERMATIC_AUTO_ALIASES !== '0' : file.autoAliases !== false
|
|
193
|
+
if (autoAliases) {
|
|
194
|
+
for (const ip of localIPv4s()) aliases.add(ip)
|
|
195
|
+
// the OS hostname is only one of our names when we use it for identity: a machine given its own
|
|
196
|
+
// name (e.g. a clone renamed with --host) must not answer for its twin's hostname
|
|
197
|
+
if (!explicit) try { const o = normHost(os.hostname()); if (!isLocalhost(o)) aliases.add(o) } catch {} // skipped if not a valid host
|
|
198
|
+
}
|
|
199
|
+
aliases.delete(host)
|
|
200
|
+
return {
|
|
201
|
+
dir: homeDir(),
|
|
202
|
+
identity,
|
|
203
|
+
net,
|
|
204
|
+
host,
|
|
205
|
+
aliases: [...aliases],
|
|
206
|
+
port: Number(process.env.NATTERMATIC_PORT || file.port || 7777),
|
|
207
|
+
peers: (file.peers || []).map(parsePeer).filter(p => p !== null), // extra peers beyond LAN discovery (Tailscale names, IPs)
|
|
208
|
+
routes: file.routes || {}, // host -> "ip:port" overrides
|
|
209
|
+
discoveryPort: Number(process.env.NATTERMATIC_DISCOVERY_PORT || file.discoveryPort || 0) || undefined,
|
|
210
|
+
discovery: process.env.NATTERMATIC_DISCOVERY ? process.env.NATTERMATIC_DISCOVERY !== '0' : file.discovery !== false,
|
|
211
|
+
ttlMs: Math.min(Number(file.ttlDays) > 0 ? Number(file.ttlDays) * 86400_000 : 7 * 86400_000, MAX_TTL_MS),
|
|
212
|
+
autoAliases,
|
|
213
|
+
maxWaiting: Number(file.maxWaiting) > 0 ? Number(file.maxWaiting) : 2000, // unread messages for one session
|
|
214
|
+
maxStoreBytes: (Number(file.maxStoreMB) > 0 ? Number(file.maxStoreMB) : 1024) * 2 ** 20,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Merge `obj` into config.json. @param {import('./types.js').ConfigFile} obj */
|
|
219
|
+
export function writeConfig(obj) {
|
|
220
|
+
ensureHomeDir()
|
|
221
|
+
const next = { ...readConfigFile(), ...obj }
|
|
222
|
+
writeAtomic(configPath(), JSON.stringify(next, null, 2) + '\n', 0o600)
|
|
223
|
+
return next
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** config.json's contents, or null if there is none; a file that exists but can't be read or parsed
|
|
227
|
+
* throws (never treated as empty, which would silently change this machine's settings). */
|
|
228
|
+
/** @returns {import('./types.js').ConfigFile | null} */
|
|
229
|
+
export function readConfigFile() {
|
|
230
|
+
let text
|
|
231
|
+
try { text = fs.readFileSync(configPath(), 'utf8') } catch (e) {
|
|
232
|
+
if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') return null
|
|
233
|
+
throw new Error(`cannot read ${configPath()}: ${errMsg(e)}`)
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const v = JSON.parse(text)
|
|
237
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('not a JSON object')
|
|
238
|
+
return v
|
|
239
|
+
} catch (e) { throw new Error(`bad config ${configPath()}: ${errMsg(e)} — fix the file (or delete it to start over)`) }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The message of a caught value (which may not be an Error), with a network cause code if any.
|
|
244
|
+
* A connection to a name with several addresses fails with an AggregateError whose own message is
|
|
245
|
+
* empty: report the codes of the attempts instead.
|
|
246
|
+
* @param {unknown} e
|
|
247
|
+
*/
|
|
248
|
+
export const errMsg = e => {
|
|
249
|
+
if (!(e instanceof Error)) return String(e)
|
|
250
|
+
const cause = /** @type {any} */ (e).cause
|
|
251
|
+
const agg = e instanceof AggregateError ? e : cause instanceof AggregateError ? cause : null
|
|
252
|
+
if (agg && agg.errors.length) {
|
|
253
|
+
const codes = [...new Set(agg.errors.map(x => x?.code || (x instanceof Error ? x.message : String(x))))]
|
|
254
|
+
const desc = `connect ${codes.join(', ')}${agg.errors.length > 1 ? ` (tried ${agg.errors.length} addresses)` : ''}`
|
|
255
|
+
return e === agg ? (e.message ? `${e.message}: ${desc}` : desc) : `${e.message}: ${desc}`
|
|
256
|
+
}
|
|
257
|
+
const code = cause?.code
|
|
258
|
+
return code && !e.message.includes(code) ? `${e.message} (${code})` : e.message
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The network error code behind a caught value (its own, its cause's, or the first attempt's). @param {unknown} e @returns {string} */
|
|
262
|
+
export const errCode = e => {
|
|
263
|
+
const x = /** @type {any} */ (e)
|
|
264
|
+
if (!x || typeof x !== 'object') return ''
|
|
265
|
+
if (typeof x.code === 'string') return x.code
|
|
266
|
+
if (x instanceof AggregateError) return errCode(x.errors[0])
|
|
267
|
+
return x.cause ? errCode(x.cause) : ''
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A hint for errors with a well-known local cause: on macOS, a process without Local Network
|
|
272
|
+
* permission gets EHOSTUNREACH for every machine on the LAN.
|
|
273
|
+
* @param {unknown} e
|
|
274
|
+
*/
|
|
275
|
+
export const netHint = e => {
|
|
276
|
+
const code = errCode(e)
|
|
277
|
+
if (process.platform === 'darwin' && (code === 'EHOSTUNREACH' || code === 'ENETUNREACH'))
|
|
278
|
+
return ' (if that machine is on, macOS may be blocking nattermatic from the local network: check System Settings → Privacy & Security → Local Network)'
|
|
279
|
+
return ''
|
|
280
|
+
}
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** Constant-time string comparison; false unless both are strings. @param {unknown} a @param {unknown} b */
|
|
4
|
+
function safeEq(a, b) {
|
|
5
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
6
|
+
const x = Buffer.from(a), y = Buffer.from(b)
|
|
7
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @param {string} s */
|
|
11
|
+
export const sha256 = s => crypto.createHash('sha256').update(s).digest('base64url')
|
|
12
|
+
|
|
13
|
+
/** @param {crypto.KeyLike} key @param {string} payload */
|
|
14
|
+
const sign = (key, payload) => crypto.sign('sha256', Buffer.from(payload), key).toString('base64url')
|
|
15
|
+
/** @param {crypto.KeyLike} pub @param {string} payload @param {unknown} sig */
|
|
16
|
+
function verify(pub, payload, sig) {
|
|
17
|
+
if (typeof sig !== 'string' || sig.length > 200) return false
|
|
18
|
+
try { return crypto.verify('sha256', Buffer.from(payload), pub, Buffer.from(sig, 'base64url')) } catch { return false }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {Omit<import('./types.js').Envelope, 'sig'>} m */
|
|
22
|
+
const envPayload = m => JSON.stringify(['agentlink-env-1', m.id, m.from, m.to, m.sent_at, m.expires_at, m.body])
|
|
23
|
+
/**
|
|
24
|
+
* Envelope signature by the sending machine's key: end to end, so nobody relaying a message
|
|
25
|
+
* (holders, other members) can alter it, and a member can't send as another machine.
|
|
26
|
+
* @param {string} key PEM private key @param {Omit<import('./types.js').Envelope, 'sig'>} m
|
|
27
|
+
*/
|
|
28
|
+
export const signEnvelope = (key, m) => sign(key, envPayload(m))
|
|
29
|
+
/** @param {crypto.KeyLike} pub @param {import('./types.js').Envelope} m */
|
|
30
|
+
export const verifyEnvelope = (pub, m) => verify(pub, envPayload(m), m.sig)
|
|
31
|
+
|
|
32
|
+
/** @param {Omit<import('./types.js').RosterRecord, 'sig'>} r */
|
|
33
|
+
const recPayload = r => JSON.stringify(['agentlink-roster-1', r.type, r.host ?? '', r.fp, r.cert ?? '', r.at, r.by, r.dk ?? '',
|
|
34
|
+
...(r.type === 'remove' ? [(r.seen ?? []).join(',')] : r.type === 'snapshot' ? [JSON.stringify(r.list), JSON.stringify(r.keys)] : [])])
|
|
35
|
+
/**
|
|
36
|
+
* A short id for a roster record (what a remove record lists in `seen`): of its content, not its
|
|
37
|
+
* signature. A signature can be re-encoded by anyone and still verify, so it can't say which record
|
|
38
|
+
* this is, and a copy with a different signature is the same record.
|
|
39
|
+
* @param {Omit<import('./types.js').RosterRecord, 'sig'>} r
|
|
40
|
+
*/
|
|
41
|
+
export const recId = r => { let id = ids.get(r); if (!id) ids.set(r, id = sha256(recPayload(r)).slice(0, 22)); return id }
|
|
42
|
+
// records aren't changed once made, and replaying a roster asks for each one's id several times
|
|
43
|
+
/** @type {WeakMap<object, string>} */
|
|
44
|
+
const ids = new WeakMap()
|
|
45
|
+
/**
|
|
46
|
+
* What a check of a record is remembered under: its whole content and signature. Never the signature
|
|
47
|
+
* alone, or a signature that was good for one record would pass for any other record carrying it.
|
|
48
|
+
* @param {import('./types.js').RosterRecord} r
|
|
49
|
+
*/
|
|
50
|
+
export const recKey = r => sha256(JSON.stringify([recPayload(r), r.sig]))
|
|
51
|
+
/** @param {string} key @param {Omit<import('./types.js').RosterRecord, 'sig'>} r */
|
|
52
|
+
export const signRecord = (key, r) => sign(key, recPayload(r))
|
|
53
|
+
/** @param {crypto.KeyLike} pub @param {import('./types.js').RosterRecord} r */
|
|
54
|
+
export const verifyRecord = (pub, r) => verify(pub, recPayload(r), r.sig)
|
|
55
|
+
|
|
56
|
+
/** A fresh discovery key (shared by members, only ever sent over member TLS connections). */
|
|
57
|
+
export const newDiscoveryKey = () => crypto.randomBytes(32).toString('base64')
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* LAN discovery beacons are encrypted under the discovery key, so people on the same network
|
|
61
|
+
* (a café) see opaque packets: no machine name, nothing stable to recognise a laptop by.
|
|
62
|
+
* @param {string} dk base64 key @param {object} obj
|
|
63
|
+
*/
|
|
64
|
+
export function sealBeacon(dk, obj) {
|
|
65
|
+
const iv = crypto.randomBytes(12)
|
|
66
|
+
const c = crypto.createCipheriv('aes-256-gcm', Buffer.from(dk, 'base64'), iv)
|
|
67
|
+
const body = Buffer.concat([c.update(JSON.stringify(obj)), c.final()])
|
|
68
|
+
return Buffer.concat([iv, body, c.getAuthTag()])
|
|
69
|
+
}
|
|
70
|
+
/** @param {string[]} dks keys to try @param {Buffer} buf @returns {any} the beacon, or null */
|
|
71
|
+
export function openBeacon(dks, buf) {
|
|
72
|
+
if (buf.length < 29 || buf.length > 2048) return null
|
|
73
|
+
for (const dk of [...dks].reverse()) { // the current key is the one nearly every beacon uses
|
|
74
|
+
try {
|
|
75
|
+
const d = crypto.createDecipheriv('aes-256-gcm', Buffer.from(dk, 'base64'), buf.subarray(0, 12))
|
|
76
|
+
d.setAuthTag(buf.subarray(buf.length - 16))
|
|
77
|
+
return JSON.parse(Buffer.concat([d.update(buf.subarray(12, buf.length - 16)), d.final()]).toString('utf8'))
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A random URL- and shell-safe token. */
|
|
84
|
+
export const randomToken = (bytes = 18) => {
|
|
85
|
+
for (;;) { const s = crypto.randomBytes(bytes).toString('base64url'); if (!s.startsWith('-')) return s }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- in-band identity (see docs/design/unlinkable-keys.md) ----
|
|
89
|
+
// A node's TLS certificate is a throwaway, so the handshake itself proves nothing. Instead, on each
|
|
90
|
+
// connection, the client first proves it holds a discovery key (the "gate": a stranger gets a 404
|
|
91
|
+
// like any other path), the server then signs the connection's TLS exporter with its machine key,
|
|
92
|
+
// and only then does the client sign it with its own and send its request. Both signatures are
|
|
93
|
+
// bound to that one TLS session, so they can't be replayed or relayed onto another connection.
|
|
94
|
+
|
|
95
|
+
/** @param {string} dk */
|
|
96
|
+
const gateKey = dk => crypto.createHmac('sha256', Buffer.from(dk, 'base64')).update('agentlink-gate-1').digest()
|
|
97
|
+
/** @param {string} dk @param {Buffer} ekm */
|
|
98
|
+
const gateTag = (dk, ekm) => crypto.createHmac('sha256', gateKey(dk)).update(ekm).digest('base64url')
|
|
99
|
+
const GATE_TAGS = 4
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Tags proving we hold the network's discovery key, for one connection (`ekm`: its TLS exporter).
|
|
103
|
+
* The newest few keys, so a node that missed a removal (and its key rotation) still recognises us.
|
|
104
|
+
* @param {string[]} dks oldest first @param {Buffer} ekm
|
|
105
|
+
*/
|
|
106
|
+
export const gateTags = (dks, ekm) => dks.slice(-GATE_TAGS).reverse().map(dk => gateTag(dk, ekm)).join(',')
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Does `header` hold a tag for one of `dks`? Old keys count too: they only let a client learn which
|
|
110
|
+
* member it is talking to, and anyone who ever held one already has the member list.
|
|
111
|
+
* @param {string[]} dks @param {Buffer} ekm @param {unknown} header
|
|
112
|
+
*/
|
|
113
|
+
export function gateOk(dks, ekm, header) {
|
|
114
|
+
if (typeof header !== 'string' || header.length > 64 * GATE_TAGS) return false
|
|
115
|
+
const tags = header.split(',').slice(0, GATE_TAGS)
|
|
116
|
+
return dks.some(dk => { const want = gateTag(dk, ekm); return tags.some(t => safeEq(t, want)) })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {'server' | 'client'} role @param {Buffer} ekm */
|
|
120
|
+
const authPayload = (role, ekm) => JSON.stringify(['agentlink-auth-1', role, ekm.toString('base64')])
|
|
121
|
+
/** Sign this connection's exporter as `role`. @param {string} key PEM @param {'server' | 'client'} role @param {Buffer} ekm */
|
|
122
|
+
export const proveAuth = (key, role, ekm) => sign(key, authPayload(role, ekm))
|
|
123
|
+
/** @param {crypto.KeyLike} pub @param {'server' | 'client'} role @param {Buffer} ekm @param {unknown} sig */
|
|
124
|
+
export const checkAuth = (pub, role, ekm, sig) => verify(pub, authPayload(role, ekm), sig)
|