@runwingman/flightdeck-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
2
+ import { decryptFromNpub, encryptForNpub, encodeNsec, decodeNsec, createNip98AuthHeader } from './nostr.js';
3
+
4
+ /**
5
+ * Generate a fresh workspace session keypair and produce the encrypted blob envelope.
6
+ *
7
+ * @param {Uint8Array} userSecret - The user's real Nostr private key
8
+ * @param {string} userNpub - The user's real npub
9
+ * @param {string} workspaceOwnerNpub - The workspace identity
10
+ * @returns {{ blob: object, wsKeySecret: Uint8Array, wsKeyNpub: string }}
11
+ */
12
+ export function generateWorkspaceKey(userSecret, userNpub, workspaceOwnerNpub) {
13
+ const wsKeySecret = generateSecretKey();
14
+ const wsKeyPubkey = getPublicKey(wsKeySecret);
15
+ const wsKeyNpub = nip19.npubEncode(wsKeyPubkey);
16
+ const wsKeyNsec = encodeNsec(wsKeySecret);
17
+
18
+ const encryptedNsec = encryptForNpub(userSecret, userNpub, wsKeyNsec);
19
+
20
+ const blob = {
21
+ version: 1,
22
+ workspace_owner_npub: workspaceOwnerNpub,
23
+ ws_key_npub: wsKeyNpub,
24
+ ws_key_epoch: 1,
25
+ encrypted_nsec: encryptedNsec,
26
+ encrypted_by_npub: userNpub,
27
+ created_at: new Date().toISOString(),
28
+ };
29
+
30
+ return { blob, wsKeySecret, wsKeyNpub };
31
+ }
32
+
33
+ /**
34
+ * Decrypt a workspace key blob using the user's real key.
35
+ * Validates that the blob was encrypted by the expected npub.
36
+ *
37
+ * @param {object} blob - The encrypted workspace key blob envelope
38
+ * @param {Uint8Array} userSecret - The user's real Nostr private key
39
+ * @param {string} userNpub - The user's real npub (for validation)
40
+ * @returns {{ wsKeySecret: Uint8Array, wsKeyNpub: string, wsKeyEpoch: number }}
41
+ */
42
+ export function decryptWorkspaceKey(blob, userSecret, userNpub) {
43
+ if (blob.encrypted_by_npub !== userNpub) {
44
+ throw new Error(
45
+ `Workspace key blob was encrypted by ${blob.encrypted_by_npub}, not ${userNpub}. Possible key substitution.`
46
+ );
47
+ }
48
+
49
+ const wsKeyNsec = decryptFromNpub(userSecret, blob.encrypted_by_npub, blob.encrypted_nsec);
50
+ const wsKeySecret = decodeNsec(wsKeyNsec);
51
+
52
+ // Validate the decrypted key matches the blob's stated ws_key_npub
53
+ const derivedPubkey = getPublicKey(wsKeySecret);
54
+ const derivedNpub = nip19.npubEncode(derivedPubkey);
55
+ if (derivedNpub !== blob.ws_key_npub) {
56
+ throw new Error(
57
+ `Decrypted workspace key npub ${derivedNpub} does not match blob ws_key_npub ${blob.ws_key_npub}. Blob may be corrupt.`
58
+ );
59
+ }
60
+
61
+ return {
62
+ wsKeySecret,
63
+ wsKeyNpub: blob.ws_key_npub,
64
+ wsKeyEpoch: blob.ws_key_epoch ?? 1,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Build a workspace session object suitable for use in translators and client auth.
70
+ * This replaces the real session for runtime operations.
71
+ *
72
+ * @param {Uint8Array} wsKeySecret - The decrypted workspace session private key
73
+ * @param {string} wsKeyNpub - The workspace session public key (npub)
74
+ * @param {number} wsKeyEpoch - The key epoch
75
+ * @param {string} userNpub - The user's real npub (for identity resolution)
76
+ * @returns {object} A workspace session object
77
+ */
78
+ export function buildWorkspaceSession(wsKeySecret, wsKeyNpub, wsKeyEpoch, userNpub) {
79
+ const pubkey = getPublicKey(wsKeySecret);
80
+ return {
81
+ secret: wsKeySecret,
82
+ pubkey,
83
+ npub: wsKeyNpub,
84
+ userNpub,
85
+ wsKeyEpoch,
86
+ isWorkspaceKey: true,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Store a workspace key blob in the local SQLite database.
92
+ *
93
+ * @param {object} db - SQLite database handle
94
+ * @param {object} blob - The encrypted workspace key blob envelope
95
+ * @param {string} userNpub - The user's real npub
96
+ */
97
+ export function cacheWorkspaceKeyBlob(db, blob, userNpub) {
98
+ db.prepare(`
99
+ INSERT INTO workspace_keys (workspace_owner_npub, user_npub, ws_key_npub, ws_key_epoch, encrypted_blob, cached_at)
100
+ VALUES (?, ?, ?, ?, ?, ?)
101
+ ON CONFLICT(workspace_owner_npub) DO UPDATE SET
102
+ user_npub = excluded.user_npub,
103
+ ws_key_npub = excluded.ws_key_npub,
104
+ ws_key_epoch = excluded.ws_key_epoch,
105
+ encrypted_blob = excluded.encrypted_blob,
106
+ cached_at = excluded.cached_at
107
+ `).run(
108
+ blob.workspace_owner_npub,
109
+ userNpub,
110
+ blob.ws_key_npub,
111
+ blob.ws_key_epoch ?? 1,
112
+ JSON.stringify(blob),
113
+ new Date().toISOString(),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Retrieve a cached workspace key blob from the local SQLite database.
119
+ *
120
+ * @param {object} db - SQLite database handle
121
+ * @param {string} workspaceOwnerNpub - The workspace identity
122
+ * @returns {object|null} The parsed blob envelope, or null if not cached
123
+ */
124
+ export function getCachedWorkspaceKeyBlob(db, workspaceOwnerNpub) {
125
+ const row = db.prepare(
126
+ `SELECT encrypted_blob FROM workspace_keys WHERE workspace_owner_npub = ?`
127
+ ).get(workspaceOwnerNpub);
128
+ if (!row?.encrypted_blob) return null;
129
+ return JSON.parse(row.encrypted_blob);
130
+ }
131
+
132
+ /**
133
+ * Remove a cached workspace key blob, optionally only when it matches the
134
+ * expected workspace key npub.
135
+ *
136
+ * @param {object} db - SQLite database handle
137
+ * @param {string} workspaceOwnerNpub - The workspace identity
138
+ * @param {string|null} wsKeyNpub - Optional workspace key npub guard
139
+ */
140
+ export function deleteCachedWorkspaceKeyBlob(db, workspaceOwnerNpub, wsKeyNpub = null) {
141
+ if (wsKeyNpub) {
142
+ db.prepare(
143
+ `DELETE FROM workspace_keys WHERE workspace_owner_npub = ? AND ws_key_npub = ?`
144
+ ).run(workspaceOwnerNpub, wsKeyNpub);
145
+ return;
146
+ }
147
+ db.prepare(
148
+ `DELETE FROM workspace_keys WHERE workspace_owner_npub = ?`
149
+ ).run(workspaceOwnerNpub);
150
+ }
151
+
152
+ /**
153
+ * Confirm Tower can resolve a workspace key back to the expected real user npub.
154
+ *
155
+ * @param {object} options
156
+ * @param {object} options.client - SuperbasedClient instance using real-user auth
157
+ * @param {object} options.config - Workspace config
158
+ * @param {string} options.wsKeyNpub - Workspace key npub
159
+ * @param {string} options.userNpub - Real user npub
160
+ * @returns {Promise<boolean>}
161
+ */
162
+ export async function isWorkspaceKeyRegistered({ client, config, wsKeyNpub, userNpub }) {
163
+ const result = await client.fetchWorkspaceKeyMappings(config.workspaceOwnerNpub);
164
+ return (result.mappings ?? []).some((entry) => (
165
+ entry?.ws_key_npub === wsKeyNpub && entry?.user_npub === userNpub
166
+ ));
167
+ }
168
+
169
+ /**
170
+ * Bootstrap the workspace session key for a given workspace.
171
+ * - If a cached blob exists locally, decrypt it.
172
+ * - Otherwise, generate a new keypair, register with Tower, then cache it.
173
+ *
174
+ * @param {object} options
175
+ * @param {object} options.db - SQLite database handle
176
+ * @param {object} options.realSession - The user's real Nostr session { secret, npub }
177
+ * @param {object} options.config - Workspace config { workspaceOwnerNpub, directHttpsUrl }
178
+ * @param {object} options.client - SuperbasedClient instance (uses real session for registration)
179
+ * @returns {Promise<{ wsSession: object, blob: object }>}
180
+ */
181
+ export async function bootstrapWorkspaceKey({ db, realSession, config, client }) {
182
+ const cached = getCachedWorkspaceKeyBlob(db, config.workspaceOwnerNpub);
183
+
184
+ if (cached) {
185
+ const { wsKeySecret, wsKeyNpub, wsKeyEpoch } = decryptWorkspaceKey(
186
+ cached, realSession.secret, realSession.npub
187
+ );
188
+ const wsSession = buildWorkspaceSession(wsKeySecret, wsKeyNpub, wsKeyEpoch, realSession.npub);
189
+ return { wsSession, blob: cached };
190
+ }
191
+
192
+ // First time: generate and register before caching locally.
193
+ const { blob, wsKeySecret, wsKeyNpub } = generateWorkspaceKey(
194
+ realSession.secret, realSession.npub, config.workspaceOwnerNpub
195
+ );
196
+
197
+ // Register with Tower (uses real session NIP-98 auth)
198
+ await client.registerWorkspaceKey({
199
+ workspace_owner_npub: config.workspaceOwnerNpub,
200
+ ws_key_npub: wsKeyNpub,
201
+ });
202
+
203
+ cacheWorkspaceKeyBlob(db, blob, realSession.npub);
204
+
205
+ const wsSession = buildWorkspaceSession(wsKeySecret, wsKeyNpub, 1, realSession.npub);
206
+ return { wsSession, blob };
207
+ }
208
+
209
+ /**
210
+ * Create a NIP-98 auth header using the workspace session key.
211
+ */
212
+ export function createWorkspaceNip98Auth(wsSession, url, method, body) {
213
+ return createNip98AuthHeader(url, method, body, wsSession.secret);
214
+ }