@wolpertingerlabs/drawlatch 1.0.0-alpha.29 → 1.0.0-alpha.31
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/bin/drawlatch.js +181 -7
- package/dist/remote/admin-mutations.d.ts +6 -0
- package/dist/remote/admin-mutations.js +71 -1
- package/dist/remote/admin-types.d.ts +4 -0
- package/dist/remote/admin.js +1 -0
- package/dist/remote/caller-bootstrap.d.ts +81 -31
- package/dist/remote/caller-bootstrap.js +204 -75
- package/dist/remote/server.js +26 -39
- package/dist/shared/config.d.ts +7 -0
- package/dist/shared/crypto/index.d.ts +1 -1
- package/dist/shared/crypto/index.js +1 -1
- package/dist/shared/crypto/key-manager.d.ts +14 -0
- package/dist/shared/crypto/key-manager.js +25 -3
- package/dist/shared/protocol/caller-bundle-crypto.d.ts +37 -0
- package/dist/shared/protocol/caller-bundle-crypto.js +62 -0
- package/dist/shared/protocol/caller-bundle.d.ts +75 -0
- package/dist/shared/protocol/caller-bundle.js +24 -0
- package/frontend/dist/assets/index-BdCSPSZK.js +282 -0
- package/frontend/dist/assets/{index-D4k5QKBP.css → index-BmK26bY2.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-IzwnSOmu.js +0 -267
|
@@ -1,61 +1,238 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Caller provisioning — drawlatch is the sole issuer of caller key material.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* One primitive, two delivery modes (see plans/caller-credential-issuance.md):
|
|
5
|
+
* - `issueCallerBundle()` — mint a keypair IN MEMORY, persist only the public
|
|
6
|
+
* half, and return the credential bundle (download via the admin API, or the
|
|
7
|
+
* `drawlatch issue-caller` CLI). The caller PRIVATE key never touches
|
|
8
|
+
* drawlatch disk; it lives only inside the returned bundle.
|
|
9
|
+
* - `issueLocalCaller()` — same primitive, but write the UNPACKED key files
|
|
10
|
+
* straight into a co-located callboard's keys dir over the shared filesystem.
|
|
11
|
+
* The same-host write IS the trust proof (replaces the old enroll-token dance).
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* password-gated `POST /api/admin/callers` admin endpoint).
|
|
13
|
-
* - `autoEnroll()` — loopback / shared-fs path: a client proves filesystem
|
|
14
|
-
* access by presenting the one-time token drawlatch writes into the config
|
|
15
|
-
* dir at startup, and gets a caller provisioned with zero interaction.
|
|
13
|
+
* `createCallerWithKeys()` (priv+pub on drawlatch disk) is retained for the
|
|
14
|
+
* password-gated `POST /api/admin/callers` create endpoint and tests.
|
|
16
15
|
*/
|
|
17
16
|
import fs from 'node:fs';
|
|
18
17
|
import path from 'node:path';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
18
|
+
import { loadRemoteConfig, saveRemoteConfig, getCallerKeysDir, } from '../shared/config.js';
|
|
19
|
+
import { createCaller, exportServerPublicKeys, serverFingerprint, saveCallerPublicKeys, } from '../shared/crypto/key-manager.js';
|
|
20
|
+
import { generateKeyBundle, serializeKeyBundle, extractPublicKeys, serializePublicKeys, fingerprint, } from '../shared/crypto/keys.js';
|
|
21
|
+
import { generateBundleSalt, deriveBundleKey, encryptBundleField, DEFAULT_SCRYPT_PARAMS, } from '../shared/protocol/caller-bundle-crypto.js';
|
|
22
22
|
import { setEnvVars } from '../shared/env-utils.js';
|
|
23
23
|
import { createLogger } from '../shared/logger.js';
|
|
24
24
|
const log = createLogger('caller-bootstrap');
|
|
25
25
|
/** Valid caller alias: starts with a letter/number, then letters/numbers/-/_. */
|
|
26
26
|
export const CALLER_ALIAS_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
27
|
+
function assertValidAlias(alias) {
|
|
28
|
+
if (!CALLER_ALIAS_REGEX.test(alias)) {
|
|
29
|
+
throw new Error('Invalid alias: must start with a letter or number and contain only ' +
|
|
30
|
+
'letters, numbers, hyphens, and underscores');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Clone the `default` caller's connections (the new-caller default access). */
|
|
34
|
+
function defaultConnections(config) {
|
|
35
|
+
const defaultCaller = config.callers.default;
|
|
36
|
+
return [...(defaultCaller?.connections ?? [])];
|
|
37
|
+
}
|
|
27
38
|
/**
|
|
28
|
-
* Create a caller WITH a fresh keypair
|
|
29
|
-
*
|
|
39
|
+
* Create a caller WITH a fresh keypair on drawlatch disk (priv+pub), registered
|
|
40
|
+
* in remote.config.json. Used by the dashboard "New caller" action.
|
|
30
41
|
*
|
|
31
42
|
* Throws on invalid alias, or if the caller (config entry or key dir) exists.
|
|
32
43
|
*/
|
|
33
44
|
export function createCallerWithKeys(alias, opts = {}) {
|
|
34
|
-
|
|
35
|
-
throw new Error('Invalid alias: must start with a letter or number and contain only ' +
|
|
36
|
-
'letters, numbers, hyphens, and underscores');
|
|
37
|
-
}
|
|
45
|
+
assertValidAlias(alias);
|
|
38
46
|
const config = loadRemoteConfig();
|
|
39
|
-
if (alias in config.callers ||
|
|
47
|
+
if (alias in config.callers || callerKeyDirExists(alias)) {
|
|
40
48
|
throw new Error(`Caller "${alias}" already exists`);
|
|
41
49
|
}
|
|
42
50
|
// Generate the caller's keypair under keys/callers/<alias>/.
|
|
43
|
-
const { publicKeys, fingerprint } = createCaller(alias);
|
|
51
|
+
const { publicKeys, fingerprint: fp } = createCaller(alias);
|
|
44
52
|
const name = opts.name ?? alias;
|
|
45
|
-
const
|
|
46
|
-
const connections = opts.connections ?? [...(defaultCaller?.connections ?? [])];
|
|
53
|
+
const connections = opts.connections ?? defaultConnections(config);
|
|
47
54
|
config.callers[alias] = { name, connections };
|
|
48
55
|
saveRemoteConfig(config);
|
|
49
|
-
log.info(`Created caller "${alias}" with keypair (${connections.length} connection(s), fingerprint ${
|
|
56
|
+
log.info(`Created caller "${alias}" with keypair (${connections.length} connection(s), fingerprint ${fp})`);
|
|
50
57
|
return {
|
|
51
58
|
alias,
|
|
52
59
|
name,
|
|
53
|
-
fingerprint,
|
|
60
|
+
fingerprint: fp,
|
|
54
61
|
publicKeys,
|
|
55
62
|
keysDir: path.join(getCallerKeysDir(), alias),
|
|
56
63
|
connections,
|
|
57
64
|
};
|
|
58
65
|
}
|
|
66
|
+
/** Whether a caller key directory already holds key material. */
|
|
67
|
+
function callerKeyDirExists(alias) {
|
|
68
|
+
const dir = path.join(getCallerKeysDir(), alias);
|
|
69
|
+
return (fs.existsSync(path.join(dir, 'signing.pub.pem')) ||
|
|
70
|
+
fs.existsSync(path.join(dir, 'signing.key.pem')));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Issue (or re-issue / rotate) a caller credential bundle.
|
|
74
|
+
*
|
|
75
|
+
* 1. Generate the Ed25519 + X25519 keypair IN MEMORY.
|
|
76
|
+
* 2. Register/update the caller in remote.config.json (connections, name,
|
|
77
|
+
* source='bundle-issued').
|
|
78
|
+
* 3. Persist ONLY the public keys to keys/callers/<alias>/ (the private key
|
|
79
|
+
* never touches drawlatch disk).
|
|
80
|
+
* 4. Assemble the v1 bundle (caller priv+pub, server pub-only, endpoint,
|
|
81
|
+
* fingerprints, connections), optionally passphrase-wrapping the two
|
|
82
|
+
* private PEMs.
|
|
83
|
+
* 5. Zero the derived key material from memory and return the bundle.
|
|
84
|
+
*
|
|
85
|
+
* Re-issuing an existing caller mints a FRESH keypair and overwrites the stored
|
|
86
|
+
* public key — invalidating the prior credential (the old private key can no
|
|
87
|
+
* longer authenticate). The bundle is returned once; drawlatch keeps no copy of
|
|
88
|
+
* the private material, so it is unrecoverable afterward.
|
|
89
|
+
*/
|
|
90
|
+
export function issueCallerBundle(opts) {
|
|
91
|
+
const { alias, endpointUrl, passphrase } = opts;
|
|
92
|
+
assertValidAlias(alias);
|
|
93
|
+
if (typeof endpointUrl !== 'string' || endpointUrl.trim() === '') {
|
|
94
|
+
throw new Error('endpointUrl is required');
|
|
95
|
+
}
|
|
96
|
+
const config = loadRemoteConfig();
|
|
97
|
+
const existing = config.callers[alias];
|
|
98
|
+
// Mint the keypair in memory and serialize before any disk writes.
|
|
99
|
+
const bundleKeys = generateKeyBundle();
|
|
100
|
+
const serialized = serializeKeyBundle(bundleKeys);
|
|
101
|
+
const publicKeys = serializePublicKeys(extractPublicKeys(bundleKeys));
|
|
102
|
+
const callerFp = fingerprint(extractPublicKeys(bundleKeys));
|
|
103
|
+
// Resolve name + connections (preserve existing values on re-issue).
|
|
104
|
+
const name = opts.name ?? existing?.name ?? alias;
|
|
105
|
+
const connections = opts.connections ?? existing?.connections ?? defaultConnections(config);
|
|
106
|
+
// Register/update the caller, preserving any unrelated fields (env, overrides…).
|
|
107
|
+
config.callers[alias] = {
|
|
108
|
+
...(existing ?? {}),
|
|
109
|
+
name,
|
|
110
|
+
connections,
|
|
111
|
+
source: 'bundle-issued',
|
|
112
|
+
};
|
|
113
|
+
saveRemoteConfig(config);
|
|
114
|
+
// Persist ONLY the public keys (drops any stale private files from a prior mint).
|
|
115
|
+
saveCallerPublicKeys(alias, publicKeys);
|
|
116
|
+
const serverPub = exportServerPublicKeys();
|
|
117
|
+
const serverFp = serverFingerprint();
|
|
118
|
+
// Optionally passphrase-wrap the two private PEMs. The derived key is zeroed
|
|
119
|
+
// immediately after use; the plaintext PEMs are only referenced transiently.
|
|
120
|
+
let encryption = null;
|
|
121
|
+
let callerSigningPriv = serialized.signing.privateKey;
|
|
122
|
+
let callerExchangePriv = serialized.exchange.privateKey;
|
|
123
|
+
if (passphrase !== undefined && passphrase !== '') {
|
|
124
|
+
encryption = { kdf: 'scrypt', salt: generateBundleSalt(), ...DEFAULT_SCRYPT_PARAMS, alg: 'aes-256-gcm' };
|
|
125
|
+
const key = deriveBundleKey(passphrase, encryption);
|
|
126
|
+
callerSigningPriv = encryptBundleField(serialized.signing.privateKey, key);
|
|
127
|
+
callerExchangePriv = encryptBundleField(serialized.exchange.privateKey, key);
|
|
128
|
+
key.fill(0);
|
|
129
|
+
}
|
|
130
|
+
const bundle = {
|
|
131
|
+
version: 1,
|
|
132
|
+
callerAlias: alias,
|
|
133
|
+
fingerprint: callerFp,
|
|
134
|
+
createdAt: opts.createdAt ?? new Date().toISOString(),
|
|
135
|
+
expiresAt: null,
|
|
136
|
+
endpointUrl,
|
|
137
|
+
serverKeyFingerprint: serverFp,
|
|
138
|
+
connections,
|
|
139
|
+
caller: {
|
|
140
|
+
signing: { priv: callerSigningPriv, pub: serialized.signing.publicKey },
|
|
141
|
+
exchange: { priv: callerExchangePriv, pub: serialized.exchange.publicKey },
|
|
142
|
+
},
|
|
143
|
+
server: {
|
|
144
|
+
signing: { pub: serverPub.signing },
|
|
145
|
+
exchange: { pub: serverPub.exchange },
|
|
146
|
+
},
|
|
147
|
+
encryption,
|
|
148
|
+
};
|
|
149
|
+
// Best-effort: drop our references to the in-memory private material. (JS
|
|
150
|
+
// strings can't be explicitly zeroed; the guarantee that matters — the private
|
|
151
|
+
// key is never written to drawlatch disk — is enforced above.)
|
|
152
|
+
callerSigningPriv = '';
|
|
153
|
+
callerExchangePriv = '';
|
|
154
|
+
log.info(`Issued caller "${alias}" (${connections.length} connection(s), fingerprint ${callerFp}` +
|
|
155
|
+
`${encryption ? ', passphrase-wrapped' : ''}${existing ? ', re-issue' : ''})`);
|
|
156
|
+
return bundle;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Issue a caller and write the UNPACKED key files straight into a co-located
|
|
160
|
+
* callboard's keys dir over the shared filesystem (same-host write is the trust
|
|
161
|
+
* proof). drawlatch keeps only the public key; callboard gets the private key on
|
|
162
|
+
* its own disk — bit-for-bit the steady state of the old pairing flow, with no
|
|
163
|
+
* token dance.
|
|
164
|
+
*
|
|
165
|
+
* Writes, under `<keysDir>`:
|
|
166
|
+
* - callers/<alias>/{signing,exchange}.{key,pub}.pem (0600 / 0644)
|
|
167
|
+
* - server/{signing,exchange}.pub.pem (0644)
|
|
168
|
+
*/
|
|
169
|
+
export function issueLocalCaller(opts) {
|
|
170
|
+
const alias = opts.alias ?? 'callboard-local';
|
|
171
|
+
assertValidAlias(alias);
|
|
172
|
+
const config = loadRemoteConfig();
|
|
173
|
+
const existing = config.callers[alias];
|
|
174
|
+
const bundleKeys = generateKeyBundle();
|
|
175
|
+
const serialized = serializeKeyBundle(bundleKeys);
|
|
176
|
+
const publicKeys = serializePublicKeys(extractPublicKeys(bundleKeys));
|
|
177
|
+
const callerFp = fingerprint(extractPublicKeys(bundleKeys));
|
|
178
|
+
const connections = opts.connections ?? existing?.connections ?? defaultConnections(config);
|
|
179
|
+
const name = opts.name ?? existing?.name ?? alias;
|
|
180
|
+
config.callers[alias] = {
|
|
181
|
+
...(existing ?? {}),
|
|
182
|
+
name,
|
|
183
|
+
connections,
|
|
184
|
+
source: 'local-auto',
|
|
185
|
+
};
|
|
186
|
+
saveRemoteConfig(config);
|
|
187
|
+
// drawlatch keeps only the public half.
|
|
188
|
+
saveCallerPublicKeys(alias, publicKeys);
|
|
189
|
+
// Write the unpacked key files into callboard's keys dir.
|
|
190
|
+
const callerKeysDir = path.join(opts.keysDir, 'callers', alias);
|
|
191
|
+
fs.mkdirSync(callerKeysDir, { recursive: true, mode: 0o700 });
|
|
192
|
+
fs.writeFileSync(path.join(callerKeysDir, 'signing.pub.pem'), serialized.signing.publicKey, { mode: 0o644 });
|
|
193
|
+
fs.writeFileSync(path.join(callerKeysDir, 'exchange.pub.pem'), serialized.exchange.publicKey, { mode: 0o644 });
|
|
194
|
+
fs.writeFileSync(path.join(callerKeysDir, 'signing.key.pem'), serialized.signing.privateKey, { mode: 0o600 });
|
|
195
|
+
fs.writeFileSync(path.join(callerKeysDir, 'exchange.key.pem'), serialized.exchange.privateKey, { mode: 0o600 });
|
|
196
|
+
// And the server's public keys so callboard can pin the server identity.
|
|
197
|
+
const serverPub = exportServerPublicKeys();
|
|
198
|
+
const serverDir = path.join(opts.keysDir, 'server');
|
|
199
|
+
fs.mkdirSync(serverDir, { recursive: true, mode: 0o700 });
|
|
200
|
+
fs.writeFileSync(path.join(serverDir, 'signing.pub.pem'), serverPub.signing, { mode: 0o644 });
|
|
201
|
+
fs.writeFileSync(path.join(serverDir, 'exchange.pub.pem'), serverPub.exchange, { mode: 0o644 });
|
|
202
|
+
log.info(`Auto-shared local caller "${alias}" to ${callerKeysDir} ` +
|
|
203
|
+
`(${connections.length} connection(s), fingerprint ${callerFp})`);
|
|
204
|
+
return { alias, fingerprint: callerFp, callerKeysDir, connections };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* First-boot hook: when drawlatch is supervised by a co-located callboard (which
|
|
208
|
+
* sets DRAWLATCH_LOCAL_CALLER_KEYS_DIR to its own keys dir), auto-share a default
|
|
209
|
+
* caller into that dir — but only once (skipped if the caller already exists).
|
|
210
|
+
*
|
|
211
|
+
* Replaces the retired enroll-token / `/sync/auto-enroll` path.
|
|
212
|
+
*/
|
|
213
|
+
export function maybeIssueLocalCaller() {
|
|
214
|
+
const keysDir = process.env.DRAWLATCH_LOCAL_CALLER_KEYS_DIR;
|
|
215
|
+
if (!keysDir)
|
|
216
|
+
return null;
|
|
217
|
+
const alias = process.env.DRAWLATCH_LOCAL_CALLER_ALIAS ?? 'callboard-local';
|
|
218
|
+
const config = loadRemoteConfig();
|
|
219
|
+
if (alias in config.callers && callerKeyDirExists(alias)) {
|
|
220
|
+
// Already provisioned on a prior boot — nothing to do.
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const connectionsEnv = process.env.DRAWLATCH_LOCAL_CALLER_CONNECTIONS;
|
|
224
|
+
const connections = connectionsEnv
|
|
225
|
+
? connectionsEnv.split(',').map((c) => c.trim()).filter(Boolean)
|
|
226
|
+
: undefined;
|
|
227
|
+
try {
|
|
228
|
+
return issueLocalCaller({ alias, keysDir, ...(connections && { connections }) });
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
log.error(`Could not auto-share local caller "${alias}":`, err);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ── Delete ──────────────────────────────────────────────────────────────────
|
|
59
236
|
/**
|
|
60
237
|
* Delete a caller: removes its config entry, prefixed env vars, and key dir.
|
|
61
238
|
* The `default` caller cannot be deleted.
|
|
@@ -90,52 +267,4 @@ export function deleteCaller(alias) {
|
|
|
90
267
|
}
|
|
91
268
|
log.info(`Deleted caller "${alias}"`);
|
|
92
269
|
}
|
|
93
|
-
// ── Loopback / shared-fs auto-enroll ───────────────────────────────────────
|
|
94
|
-
/** Path of the one-time enroll token file inside the config dir. */
|
|
95
|
-
export function getEnrollTokenPath() {
|
|
96
|
-
return path.join(getConfigDir(), 'enroll.token');
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Write a fresh one-time enroll token into the config dir (mode 0600).
|
|
100
|
-
*
|
|
101
|
-
* Only a process with filesystem access to drawlatch's config dir can read it,
|
|
102
|
-
* which is exactly the proof-of-co-location we want for `autoEnroll()`.
|
|
103
|
-
* Called once at daemon startup; overwrites any stale token.
|
|
104
|
-
*/
|
|
105
|
-
export function writeEnrollToken() {
|
|
106
|
-
const token = randomBytes(32).toString('hex');
|
|
107
|
-
const tokenPath = getEnrollTokenPath();
|
|
108
|
-
fs.mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
109
|
-
fs.writeFileSync(tokenPath, token, { mode: 0o600 });
|
|
110
|
-
return token;
|
|
111
|
-
}
|
|
112
|
-
/** Constant-time compare of the presented token against the on-disk token. */
|
|
113
|
-
function tokenMatches(presented) {
|
|
114
|
-
const tokenPath = getEnrollTokenPath();
|
|
115
|
-
if (!fs.existsSync(tokenPath))
|
|
116
|
-
return false;
|
|
117
|
-
const onDisk = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
118
|
-
const a = Buffer.from(onDisk);
|
|
119
|
-
const b = Buffer.from(presented);
|
|
120
|
-
if (a.length !== b.length)
|
|
121
|
-
return false;
|
|
122
|
-
return timingSafeEqual(a, b);
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Auto-enroll a co-located caller by presenting the one-time enroll token.
|
|
126
|
-
*
|
|
127
|
-
* Verifies the token proves filesystem access to the config dir, then either
|
|
128
|
-
* creates a new caller with keys (if the alias is new) or returns the existing
|
|
129
|
-
* one's metadata (idempotent). The token is single-use: it is rotated on every
|
|
130
|
-
* successful enroll so a leaked token cannot be replayed.
|
|
131
|
-
*/
|
|
132
|
-
export function autoEnroll(token, alias, opts = {}) {
|
|
133
|
-
if (!tokenMatches(token)) {
|
|
134
|
-
throw new Error('Invalid or expired enroll token');
|
|
135
|
-
}
|
|
136
|
-
const result = createCallerWithKeys(alias, opts);
|
|
137
|
-
// Rotate the token so it cannot be replayed.
|
|
138
|
-
writeEnrollToken();
|
|
139
|
-
return result;
|
|
140
|
-
}
|
|
141
270
|
//# sourceMappingURL=caller-bootstrap.js.map
|
package/dist/remote/server.js
CHANGED
|
@@ -31,7 +31,7 @@ import { isSecretSetForCaller } from '../shared/env-utils.js';
|
|
|
31
31
|
import { toolHandlers } from './tool-dispatch.js';
|
|
32
32
|
import { setTunnelUrl, getTunnelUrl } from './tunnel-state.js';
|
|
33
33
|
import { migrateConfigDir } from '../shared/migrations.js';
|
|
34
|
-
import {
|
|
34
|
+
import { maybeIssueLocalCaller } from './caller-bootstrap.js';
|
|
35
35
|
import { createAdminRouter } from './admin.js';
|
|
36
36
|
import { loginHandler, logoutHandler, checkAuthHandler, changePasswordHandler, requireAuth, } from '../auth/auth.js';
|
|
37
37
|
// ── Environment loading ─────────────────────────────────────────────────────
|
|
@@ -218,8 +218,6 @@ export function createApp(options = {}) {
|
|
|
218
218
|
app.use('/sync', express.text({ type: 'text/plain', limit: '64kb' }));
|
|
219
219
|
// JSON for sync management endpoints (64kb limit — sync listen messages are tiny)
|
|
220
220
|
app.use('/sync/listen', express.json({ limit: '64kb' }));
|
|
221
|
-
// JSON for the loopback auto-enroll endpoint (item E).
|
|
222
|
-
app.use('/sync/auto-enroll', express.json({ limit: '64kb' }));
|
|
223
221
|
// Raw buffer for webhook endpoints (needed for signature verification)
|
|
224
222
|
app.use('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }));
|
|
225
223
|
// ── Dashboard auth body/cookie parsers ───────────────────────────────────
|
|
@@ -711,38 +709,19 @@ export function createApp(options = {}) {
|
|
|
711
709
|
sessions.delete(id);
|
|
712
710
|
}
|
|
713
711
|
};
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
if (
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
reloadPeer(alias);
|
|
728
|
-
res.json({
|
|
729
|
-
alias: result.alias,
|
|
730
|
-
name: result.name,
|
|
731
|
-
fingerprint: result.fingerprint,
|
|
732
|
-
keysDir: result.keysDir,
|
|
733
|
-
connections: result.connections,
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
catch (err) {
|
|
737
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
738
|
-
const status = /invalid or expired/i.test(message)
|
|
739
|
-
? 403
|
|
740
|
-
: /already exists/i.test(message)
|
|
741
|
-
? 409
|
|
742
|
-
: 400;
|
|
743
|
-
res.status(status).json({ error: message });
|
|
744
|
-
}
|
|
745
|
-
});
|
|
712
|
+
// Co-located callboard auto-share now happens at daemon boot via
|
|
713
|
+
// maybeIssueLocalCaller() (write-to-path over the shared filesystem). The old
|
|
714
|
+
// loopback `/sync/auto-enroll` enroll-token endpoint has been retired.
|
|
715
|
+
/** Endpoint URL to pin in an issued bundle when the request omits one:
|
|
716
|
+
* the public tunnel URL if active, else the daemon's own host:port. */
|
|
717
|
+
const defaultEndpointUrl = () => {
|
|
718
|
+
const tunnel = getTunnelUrl();
|
|
719
|
+
if (tunnel)
|
|
720
|
+
return tunnel;
|
|
721
|
+
const bindHost = process.env.DRAWLATCH_HOST ?? config.host;
|
|
722
|
+
const reachable = bindHost === '0.0.0.0' ? '127.0.0.1' : bindHost;
|
|
723
|
+
return `http://${reachable}:${port}`;
|
|
724
|
+
};
|
|
746
725
|
// JSON body parsing for the mutating admin endpoints (cookie-parser for /api
|
|
747
726
|
// is already installed above; the daemon keeps its per-route parser pattern).
|
|
748
727
|
app.use('/api/admin', express.json({ limit: '1mb' }));
|
|
@@ -754,6 +733,8 @@ export function createApp(options = {}) {
|
|
|
754
733
|
refreshCaller: refreshCallerSessions,
|
|
755
734
|
reloadPeer,
|
|
756
735
|
removePeer,
|
|
736
|
+
audit: (action, details) => auditLog('admin', action, details ?? {}),
|
|
737
|
+
defaultEndpointUrl,
|
|
757
738
|
version: PKG_VERSION,
|
|
758
739
|
port,
|
|
759
740
|
startedAt,
|
|
@@ -900,13 +881,19 @@ export function main() {
|
|
|
900
881
|
console.log('[remote] No callers configured — server will accept sync requests.');
|
|
901
882
|
console.log('[remote] To add callers, run: drawlatch sync');
|
|
902
883
|
}
|
|
903
|
-
//
|
|
904
|
-
//
|
|
884
|
+
// First-boot auto-share: when supervised by a co-located callboard (which sets
|
|
885
|
+
// DRAWLATCH_LOCAL_CALLER_KEYS_DIR), mint a default caller and write the unpacked
|
|
886
|
+
// key files straight into callboard's keys dir. Idempotent (skipped once the
|
|
887
|
+
// caller exists). Replaces the retired enroll-token / /sync/auto-enroll path.
|
|
905
888
|
try {
|
|
906
|
-
|
|
889
|
+
const local = maybeIssueLocalCaller();
|
|
890
|
+
if (local) {
|
|
891
|
+
console.log(`[remote] Auto-shared local caller "${local.alias}" → ${local.callerKeysDir} ` +
|
|
892
|
+
`(fingerprint ${local.fingerprint})`);
|
|
893
|
+
}
|
|
907
894
|
}
|
|
908
895
|
catch (err) {
|
|
909
|
-
console.warn('[remote] Could not
|
|
896
|
+
console.warn('[remote] Could not auto-share local caller:', err);
|
|
910
897
|
}
|
|
911
898
|
const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
|
|
912
899
|
const host = process.env.DRAWLATCH_HOST ?? config.host;
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -151,10 +151,17 @@ export interface IngestorOverrides {
|
|
|
151
151
|
* during mergeIngestorConfig(). */
|
|
152
152
|
params?: Record<string, unknown>;
|
|
153
153
|
}
|
|
154
|
+
/** How a caller's keypair was provisioned (drives the dashboard source badge). */
|
|
155
|
+
export type CallerSource = 'local-auto' | 'bundle-issued';
|
|
154
156
|
/** Per-caller access configuration */
|
|
155
157
|
export interface CallerConfig {
|
|
156
158
|
/** Human-readable name for this caller (used in audit logs) */
|
|
157
159
|
name?: string;
|
|
160
|
+
/** How this caller's keypair was provisioned:
|
|
161
|
+
* - 'bundle-issued' — minted via the credential-issuance flow (download/CLI)
|
|
162
|
+
* - 'local-auto' — auto-shared to a co-located callboard over the filesystem
|
|
163
|
+
* Absent for callers created before issuance existed (e.g. via `sync`). */
|
|
164
|
+
source?: CallerSource;
|
|
158
165
|
/** List of connection aliases — references built-in templates (e.g., "github")
|
|
159
166
|
* or custom connector aliases defined in the top-level connectors array. */
|
|
160
167
|
connections: string[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { type KeyBundle, type SerializedKeyBundle, type PublicKeyBundle, type SerializedPublicKeys, generateKeyBundle, extractPublicKeys, serializeKeyBundle, deserializeKeyBundle, serializePublicKeys, deserializePublicKeys, saveKeyBundle, loadKeyBundle, loadPublicKeys, fingerprint, } from './keys.js';
|
|
2
2
|
export { type DirectionalKey, type SessionKeys, deriveSessionKeys, EncryptedChannel, } from './channel.js';
|
|
3
|
-
export { type CreateCallerResult, createCaller, exportCallerPublicKeys, exportServerPublicKeys, importCallerPublicKeys, saveServerPublicKeys, listCallers, callerExists, serverExists, callerFingerprint, serverFingerprint, } from './key-manager.js';
|
|
3
|
+
export { type CreateCallerResult, createCaller, exportCallerPublicKeys, exportServerPublicKeys, importCallerPublicKeys, saveCallerPublicKeys, saveServerPublicKeys, listCallers, callerExists, serverExists, callerFingerprint, serverFingerprint, } from './key-manager.js';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { generateKeyBundle, extractPublicKeys, serializeKeyBundle, deserializeKeyBundle, serializePublicKeys, deserializePublicKeys, saveKeyBundle, loadKeyBundle, loadPublicKeys, fingerprint, } from './keys.js';
|
|
2
2
|
export { deriveSessionKeys, EncryptedChannel, } from './channel.js';
|
|
3
|
-
export { createCaller, exportCallerPublicKeys, exportServerPublicKeys, importCallerPublicKeys, saveServerPublicKeys, listCallers, callerExists, serverExists, callerFingerprint, serverFingerprint, } from './key-manager.js';
|
|
3
|
+
export { createCaller, exportCallerPublicKeys, exportServerPublicKeys, importCallerPublicKeys, saveCallerPublicKeys, saveServerPublicKeys, listCallers, callerExists, serverExists, callerFingerprint, serverFingerprint, } from './key-manager.js';
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
|
@@ -33,9 +33,23 @@ export declare function exportCallerPublicKeys(alias: string, opts?: KeyManagerO
|
|
|
33
33
|
* Reads from `keys/server/`.
|
|
34
34
|
*/
|
|
35
35
|
export declare function exportServerPublicKeys(opts?: KeyManagerOpts): SerializedPublicKeys;
|
|
36
|
+
/**
|
|
37
|
+
* Persist ONLY a caller's public keys under `keys/callers/<alias>/`.
|
|
38
|
+
*
|
|
39
|
+
* This is the public-only save path used by credential issuance: drawlatch mints
|
|
40
|
+
* the keypair in memory, hands the private half out in the bundle, and keeps only
|
|
41
|
+
* the public half on disk (so the private key never touches drawlatch's disk).
|
|
42
|
+
*
|
|
43
|
+
* Any stale PRIVATE key files left from a legacy on-disk mint (the old local
|
|
44
|
+
* bootstrap wrote priv+pub here) are removed, so re-issuing a previously
|
|
45
|
+
* full-keypair caller restores the public-only invariant.
|
|
46
|
+
*/
|
|
47
|
+
export declare function saveCallerPublicKeys(alias: string, keys: SerializedPublicKeys, opts?: KeyManagerOpts): void;
|
|
36
48
|
/**
|
|
37
49
|
* Import a caller's public keys. Saves under `keys/callers/<alias>/`.
|
|
38
50
|
* Used by the server to store received caller public keys (e.g., via sync).
|
|
51
|
+
*
|
|
52
|
+
* Alias for {@link saveCallerPublicKeys} — both are the public-only persist path.
|
|
39
53
|
*/
|
|
40
54
|
export declare function importCallerPublicKeys(alias: string, keys: SerializedPublicKeys, opts?: KeyManagerOpts): void;
|
|
41
55
|
/**
|
|
@@ -66,16 +66,38 @@ export function exportServerPublicKeys(opts) {
|
|
|
66
66
|
return serializePublicKeys(pub);
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
69
|
+
* Persist ONLY a caller's public keys under `keys/callers/<alias>/`.
|
|
70
|
+
*
|
|
71
|
+
* This is the public-only save path used by credential issuance: drawlatch mints
|
|
72
|
+
* the keypair in memory, hands the private half out in the bundle, and keeps only
|
|
73
|
+
* the public half on disk (so the private key never touches drawlatch's disk).
|
|
74
|
+
*
|
|
75
|
+
* Any stale PRIVATE key files left from a legacy on-disk mint (the old local
|
|
76
|
+
* bootstrap wrote priv+pub here) are removed, so re-issuing a previously
|
|
77
|
+
* full-keypair caller restores the public-only invariant.
|
|
71
78
|
*/
|
|
72
|
-
export function
|
|
79
|
+
export function saveCallerPublicKeys(alias, keys, opts) {
|
|
73
80
|
const dir = path.join(callerKeysDir(opts), alias);
|
|
74
81
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
82
|
// Validate the keys are parseable before writing
|
|
76
83
|
deserializePublicKeys(keys);
|
|
77
84
|
fs.writeFileSync(path.join(dir, 'signing.pub.pem'), keys.signing, { mode: 0o644 });
|
|
78
85
|
fs.writeFileSync(path.join(dir, 'exchange.pub.pem'), keys.exchange, { mode: 0o644 });
|
|
86
|
+
// Drop any private key material from an earlier on-disk mint.
|
|
87
|
+
for (const f of ['signing.key.pem', 'exchange.key.pem']) {
|
|
88
|
+
const p = path.join(dir, f);
|
|
89
|
+
if (fs.existsSync(p))
|
|
90
|
+
fs.rmSync(p, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Import a caller's public keys. Saves under `keys/callers/<alias>/`.
|
|
95
|
+
* Used by the server to store received caller public keys (e.g., via sync).
|
|
96
|
+
*
|
|
97
|
+
* Alias for {@link saveCallerPublicKeys} — both are the public-only persist path.
|
|
98
|
+
*/
|
|
99
|
+
export function importCallerPublicKeys(alias, keys, opts) {
|
|
100
|
+
saveCallerPublicKeys(alias, keys, opts);
|
|
79
101
|
}
|
|
80
102
|
/**
|
|
81
103
|
* Save server public keys. Writes to `keys/server/`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller credential bundle — passphrase wrapping (scrypt KDF + AES-256-GCM AEAD).
|
|
3
|
+
*
|
|
4
|
+
* Split from the types module so the format types stay free of node:crypto and
|
|
5
|
+
* can be re-exported into the dashboard. Shared with callboard, which performs
|
|
6
|
+
* the inverse (decrypt) at import time when a bundle is passphrase-protected.
|
|
7
|
+
*/
|
|
8
|
+
import type { BundleEncryption } from './caller-bundle.js';
|
|
9
|
+
/**
|
|
10
|
+
* Default scrypt parameters. N=16384 keeps the working set (128 * N * r bytes ≈
|
|
11
|
+
* 16 MiB) under Node's default scrypt `maxmem` (32 MiB) while remaining a sound
|
|
12
|
+
* interactive-use cost. The exact params are recorded in the bundle so callboard
|
|
13
|
+
* derives the same key regardless of future tuning.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_SCRYPT_PARAMS: {
|
|
16
|
+
readonly n: 16384;
|
|
17
|
+
readonly r: 8;
|
|
18
|
+
readonly p: 1;
|
|
19
|
+
};
|
|
20
|
+
/** Generate a fresh base64-encoded scrypt salt (16 bytes). */
|
|
21
|
+
export declare function generateBundleSalt(): string;
|
|
22
|
+
/**
|
|
23
|
+
* Derive the AES-256 key for bundle wrapping from a passphrase + the salt and
|
|
24
|
+
* cost parameters recorded in the bundle's `encryption` block.
|
|
25
|
+
*/
|
|
26
|
+
export declare function deriveBundleKey(passphrase: string, enc: BundleEncryption): Buffer;
|
|
27
|
+
/**
|
|
28
|
+
* Encrypt one private PEM with AES-256-GCM.
|
|
29
|
+
* Wire format: base64(IV[12] || authTag[16] || ciphertext).
|
|
30
|
+
*/
|
|
31
|
+
export declare function encryptBundleField(plaintext: string, key: Buffer): string;
|
|
32
|
+
/**
|
|
33
|
+
* Decrypt one wrapped private PEM produced by {@link encryptBundleField}.
|
|
34
|
+
* Throws on a wrong passphrase or tampered ciphertext (GCM auth failure).
|
|
35
|
+
*/
|
|
36
|
+
export declare function decryptBundleField(wrapped: string, key: Buffer): string;
|
|
37
|
+
//# sourceMappingURL=caller-bundle-crypto.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller credential bundle — passphrase wrapping (scrypt KDF + AES-256-GCM AEAD).
|
|
3
|
+
*
|
|
4
|
+
* Split from the types module so the format types stay free of node:crypto and
|
|
5
|
+
* can be re-exported into the dashboard. Shared with callboard, which performs
|
|
6
|
+
* the inverse (decrypt) at import time when a bundle is passphrase-protected.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
/**
|
|
10
|
+
* Default scrypt parameters. N=16384 keeps the working set (128 * N * r bytes ≈
|
|
11
|
+
* 16 MiB) under Node's default scrypt `maxmem` (32 MiB) while remaining a sound
|
|
12
|
+
* interactive-use cost. The exact params are recorded in the bundle so callboard
|
|
13
|
+
* derives the same key regardless of future tuning.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_SCRYPT_PARAMS = { n: 16384, r: 8, p: 1 };
|
|
16
|
+
const KEY_LEN = 32; // AES-256
|
|
17
|
+
const IV_LEN = 12; // GCM nonce
|
|
18
|
+
const TAG_LEN = 16; // GCM auth tag
|
|
19
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024; // generous headroom over the working set
|
|
20
|
+
/** Generate a fresh base64-encoded scrypt salt (16 bytes). */
|
|
21
|
+
export function generateBundleSalt() {
|
|
22
|
+
return crypto.randomBytes(16).toString('base64');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Derive the AES-256 key for bundle wrapping from a passphrase + the salt and
|
|
26
|
+
* cost parameters recorded in the bundle's `encryption` block.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveBundleKey(passphrase, enc) {
|
|
29
|
+
return crypto.scryptSync(passphrase, Buffer.from(enc.salt, 'base64'), KEY_LEN, {
|
|
30
|
+
N: enc.n,
|
|
31
|
+
r: enc.r,
|
|
32
|
+
p: enc.p,
|
|
33
|
+
maxmem: SCRYPT_MAXMEM,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Encrypt one private PEM with AES-256-GCM.
|
|
38
|
+
* Wire format: base64(IV[12] || authTag[16] || ciphertext).
|
|
39
|
+
*/
|
|
40
|
+
export function encryptBundleField(plaintext, key) {
|
|
41
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
42
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
43
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
|
44
|
+
const authTag = cipher.getAuthTag();
|
|
45
|
+
return Buffer.concat([iv, authTag, ciphertext]).toString('base64');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decrypt one wrapped private PEM produced by {@link encryptBundleField}.
|
|
49
|
+
* Throws on a wrong passphrase or tampered ciphertext (GCM auth failure).
|
|
50
|
+
*/
|
|
51
|
+
export function decryptBundleField(wrapped, key) {
|
|
52
|
+
const data = Buffer.from(wrapped, 'base64');
|
|
53
|
+
if (data.length < IV_LEN + TAG_LEN)
|
|
54
|
+
throw new Error('Wrapped field too short');
|
|
55
|
+
const iv = data.subarray(0, IV_LEN);
|
|
56
|
+
const authTag = data.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
57
|
+
const ciphertext = data.subarray(IV_LEN + TAG_LEN);
|
|
58
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
59
|
+
decipher.setAuthTag(authTag);
|
|
60
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=caller-bundle-crypto.js.map
|