@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.
@@ -1,61 +1,238 @@
1
1
  /**
2
- * Programmatic / non-interactive caller bootstrap (item E).
2
+ * Caller provisioning drawlatch is the sole issuer of caller key material.
3
3
  *
4
- * The interactive `drawlatch sync` handshake stays the path for *remote*
5
- * enrollment (a caller on another machine proving identity over the network).
6
- * This module is for the zero-friction local case: a co-located client that
7
- * shares drawlatch's filesystem can provision a caller complete with a fresh
8
- * keypair without any invite-code dance.
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
- * Two entry points:
11
- * - `createCallerWithKeys()` library function (also exposed as the
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 { randomBytes, timingSafeEqual } from 'node:crypto';
20
- import { loadRemoteConfig, saveRemoteConfig, getConfigDir, getCallerKeysDir, } from '../shared/config.js';
21
- import { createCaller, callerExists } from '../shared/crypto/key-manager.js';
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, registered in remote.config.json,
29
- * without the interactive sync handshake.
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
- if (!CALLER_ALIAS_REGEX.test(alias)) {
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 || callerExists(alias)) {
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 defaultCaller = config.callers.default;
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 ${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
@@ -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 { writeEnrollToken, autoEnroll } from './caller-bootstrap.js';
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
- // ── Loopback auto-enroll (item E) ─────────────────────────────────────
715
- // A co-located client that shares our filesystem proves co-location by
716
- // presenting the one-time enroll token drawlatch wrote into the config dir,
717
- // and gets a caller provisioned (with keys) without the invite-code dance.
718
- // Loopback-only: never reachable from off-box.
719
- app.post('/sync/auto-enroll', requireLoopback, (req, res) => {
720
- const { token, alias, name } = (req.body ?? {});
721
- if (!token || !alias) {
722
- res.status(400).json({ error: 'Missing token or alias' });
723
- return;
724
- }
725
- try {
726
- const result = autoEnroll(token, alias, name !== undefined ? { name } : {});
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
- // Drop a one-time enroll token into the config dir so a co-located client
904
- // that shares our filesystem can auto-enroll a caller (item E).
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
- writeEnrollToken();
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 write enroll token:', err);
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;
@@ -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
- * Import a caller's public keys. Saves under `keys/callers/<alias>/`.
70
- * Used by the server to store received caller public keys (e.g., via sync).
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 importCallerPublicKeys(alias, keys, opts) {
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