Repository navigation
Expand file tree
/
Copy pathconnection-secrets.ts
More file actions
293 lines (276 loc) · 13.8 KB
/
Copy pathconnection-secrets.ts
File metadata and controls
293 lines (276 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
import { encryptSecret, readSecret } from "./encryption";
import type { DatabaseConnection, SSHTunnelConfig, SSLConfig } from "@/lib/types";
/**
* The single answer to "which stored fields are credentials".
*
* Why these are `Record<keyof T, FieldClass>` maps and not a list of secret field names: lazy
* migration only re-writes what it reads, so a credential-bearing field nobody remembered to add
* to a list stays in the clear forever in every existing deployment. A list fails silently. These
* maps fail at `bun run typecheck` - add a field to DatabaseConnection, SSLConfig or
* SSHTunnelConfig and the object literal below stops satisfying its type until the new field is
* classified. A field has to opt OUT of scrutiny, deliberately; there is no opt-in step to forget.
*
* `nested` marks a container whose own map carries the classification, so "I did not think about
* it" and "it is a container" are different answers rather than the same silence.
*/
export type FieldClass = "secret" | "public" | "nested";
export const CONNECTION_FIELDS: Record<keyof DatabaseConnection, FieldClass> = {
id: "public",
name: "public",
type: "public",
host: "public",
port: "public",
user: "public",
password: "secret",
database: "public",
// Carries scheme://user:pass@host. The same shape src/lib/audit.ts redacts out of log lines.
connectionString: "secret",
createdAt: "public",
color: "public",
environment: "public",
group: "public",
ssl: "nested",
sshTunnel: "nested",
serviceName: "public",
instanceName: "public",
// A data-centre NAME (`datacenter1`), which the server publishes itself in
// `system.local.data_center` and prints in the driver's own error message when it
// is wrong. Nothing about it is a credential.
localDataCenter: "public",
// A DATABASE NAME (`admin`), not a credential. The password that authenticates
// against it is the secret, and it is classified above.
authSource: "public",
// A SASL MECHANISM NAME (`SCRAM-SHA-512`), which a broker's own configuration lists in the
// clear. It says how the password is checked; the password is the secret, classified above.
saslMechanism: "public",
schema: "public",
queryTimeout: "public",
// A display preference: whether this browser reads the catalog when the connection
// opens. It grants nothing and unlocks nothing.
skipObjectScan: "public",
// Whether the provider refuses writes on this connection (#1089). A mode, not a credential: it
// grants nothing, and the browser reads it to draw the Read-only marker.
readOnly: "public",
// Db2's acceptance of a cleartext password (#786). A choice about the transport, not a credential.
allowInsecureAuth: "public",
// Addresses, as `host` is. The token they receive is the secret, classified above.
dataServers: "public",
// The warehouse: a compute name, from Databend Cloud's DSN or a Snowflake account. It picks where a statement
// runs and grants nothing; the password, token or key sent with it is the secret, classified above.
warehouse: "public",
location: "public",
// Snowflake's sign-in method, `keypair` or `pat`: it says which credential is sent, not the credential itself.
authMethod: "public",
// A PEM key's contents, the key-pair credential itself.
privateKey: "secret",
// What decrypts that key: a secret for the reason the key is.
privateKeyPassphrase: "secret",
// Snowflake's role, a name Snowflake itself prints in its errors and its query history. It decides what the
// statements may do once signed in and grants nothing on its own; the key or token that signs in is the secret.
role: "public",
// The S3 signing region: a region name such as us-east-1, which the server publishes in its own errors. It decides
// how a request is signed and grants nothing; the secret access key that signs is classified above.
region: "public",
// Kubernetes: a server path prefix (also RabbitMQ's Path prefix), a list of namespace names and a certificate host
// name. Each says where requests go or what the tree shows; the credential that authenticates them is classified
// above or under ssl.
apiPath: "public",
namespaces: "public",
tlsServerName: "public",
// An AMQP port: an address, as `port` is. It grants nothing.
amqpPort: "public",
securityMode: "public",
// A thumbprint is the PUBLIC fingerprint of a public certificate: it identifies a server, it does not
// authenticate anyone, and hiding it would stop an operator reading back what they pinned.
serverCertificateThumbprint: "public",
// A policy URI is published by the server, and the other two are PATHS and not keys: the private
// key stays in the file the path names and never enters a connection record, which is the reason
// this provider takes a path at all.
securityPolicyUri: "public",
clientCertificatePath: "public",
clientPrivateKeyPath: "public",
managed: "public",
seedId: "public",
agentUser: "public",
agentPassword: "secret",
// Not `user`'s twin. `user` is a name an operator chose and can re-type; this is one
// generated, opaque half of a credential pair, so leaving it readable narrows what a
// leak has to guess from two values to one. See the field's own doc in types.ts.
apiKeyId: "secret",
apiKeySecret: "secret",
};
export const SSL_FIELDS: Record<keyof SSLConfig, FieldClass> = {
mode: "public",
// Certificates are public by construction; encrypting them costs diagnosability and buys nothing.
caCert: "public",
clientCert: "public",
clientKey: "secret",
rejectUnauthorized: "public",
};
export const SSH_TUNNEL_FIELDS: Record<keyof SSHTunnelConfig, FieldClass> = {
enabled: "public",
host: "public",
port: "public",
username: "public",
authMethod: "public",
password: "secret",
privateKey: "secret",
// Encrypting the private key and leaving the passphrase that unlocks it readable protects nothing.
passphrase: "secret",
// A fingerprint of the bastion's PUBLIC host key - the same string `ssh-keygen -lf` prints and
// `ssh-keyscan` hands out to anyone who asks. It authenticates the server TO us; it grants no
// access and unlocks nothing, so there is nothing to seal. Sealing it would also cost the one
// thing it exists for: the user has to be able to READ it to compare it against their own
// known_hosts when a mismatch is reported.
hostKeyFingerprint: "public",
};
/**
* Every classification map in this module, in one place, so a consumer that needs "which fields
* does this product classify as credentials" imports one name instead of restating three.
* (src/lib/agent/state-guard.ts derives its credential-key set from it.)
*
* Note what this is NOT: unlike the maps above, it carries no compile-time guarantee. The maps
* fail `bun run typecheck` when a field goes unclassified; nothing makes a FOURTH map appear in
* this array. Adding one is a manual step, and the direction that loses coverage silently is
* ADDING a map without registering it - the storage layer would encrypt the new field while a
* consumer deriving from this array never learns it exists. So the check that covers this lives in
* tests/unit/lib/agent/state-guard.test.ts and is reflective, not a pinned list of names: it walks
* this module's own exports and fails when one of them is a classification map this array omits.
*
* That check sees EXPORTED maps only. A fourth map kept module-private and wired straight into
* `walkConnection` is invisible to it, and would be sealed here while a consumer persisted it in
* the clear. Export any new classification map (all three above are) and the check covers it;
* recorded in docs/BACKLOG.md so the gap is tracked rather than only described.
*
* Deliberately NOT consumed by this module's own encrypt/decrypt walk. The three maps are not
* interchangeable there - each applies at a different level of a connection (root, `ssl`,
* `sshTunnel`), which a flat array cannot express - so making `walkConnection` iterate it would
* mean encoding those locations too, i.e. redesigning the walk rather than deduplicating a list.
*/
export const SECRET_FIELD_MAPS: readonly Record<string, FieldClass>[] = [
CONNECTION_FIELDS,
SSL_FIELDS,
SSH_TUNNEL_FIELDS,
];
/** Derived, never written out twice: a second hand-maintained list is a second thing that drifts. */
function secretsOf(map: Record<string, FieldClass>): string[] {
return Object.keys(map).filter((key) => map[key] === "secret");
}
const CONNECTION_SECRET_KEYS = secretsOf(CONNECTION_FIELDS);
const SSL_SECRET_KEYS = secretsOf(SSL_FIELDS);
const SSH_TUNNEL_SECRET_KEYS = secretsOf(SSH_TUNNEL_FIELDS);
/**
* Applies `transform` to each named field of `target` in place. A transform returning `undefined`
* DELETES the field rather than storing undefined, and is counted. Returns the number deleted.
*
* An empty string is skipped: there is nothing to protect, and enveloping it would turn "no
* password set" into a value the UI renders as a filled-in field.
*/
function mapSecretFields(
target: Record<string, unknown>,
keys: string[],
transform: (value: string) => string | undefined,
): number {
let dropped = 0;
for (const key of keys) {
const value = target[key];
if (typeof value !== "string" || value.length === 0) continue;
const next = transform(value);
if (next === undefined) {
delete target[key];
dropped += 1;
} else {
target[key] = next;
}
}
return dropped;
}
/**
* Walks one connection's three field groups. Both directions share this so the encrypt and decrypt
* paths can never disagree about WHICH fields they cover - the classic way a round trip loses a
* field is two walkers with two opinions.
*/
function walkConnection(
connection: DatabaseConnection,
transform: (value: string) => string | undefined,
): { connection: DatabaseConnection; dropped: number } {
const copy: Record<string, unknown> = { ...connection };
let dropped = mapSecretFields(copy, CONNECTION_SECRET_KEYS, transform);
if (copy.ssl) {
const ssl = { ...(copy.ssl as Record<string, unknown>) };
dropped += mapSecretFields(ssl, SSL_SECRET_KEYS, transform);
copy.ssl = ssl;
}
if (copy.sshTunnel) {
const tunnel = { ...(copy.sshTunnel as Record<string, unknown>) };
dropped += mapSecretFields(tunnel, SSH_TUNNEL_SECRET_KEYS, transform);
copy.sshTunnel = tunnel;
}
return { connection: copy as unknown as DatabaseConnection, dropped };
}
/**
* Seals everything except a value already PROVEN to open under the current key; leaves that one
* alone so a re-save is not a re-encryption.
*
* Deliberately not `kind === "plaintext" ? encryptSecret(value) : value`: that would also pass an
* `undecryptable` value through unchanged, and `undecryptable` is not "already sealed" - it is
* "unopenable", which a real password shaped like an envelope claim (a user's actual password is
* `v2:hunter2`, or a corrupted three-segment value) satisfies just as well as genuine ciphertext
* does. This path is fed from `localStorage` through `useStorageSync`, which holds plaintext by
* design, so a value reaching here is overwhelmingly a real credential, not corruption in transit;
* encrypting it is the safe default. `decrypted` is the only outcome that proves the value already
* opens under this key, so it is the only one left untouched.
*/
function sealIfPlaintext(value: string): string {
return readSecret(value).kind === "decrypted" ? value : encryptSecret(value);
}
/** Opens a sealed value, passes a legacy plaintext through, and returns undefined for a dead one. */
function openOrDrop(value: string): string | undefined {
const result = readSecret(value);
return result.kind === "undecryptable" ? undefined : result.value;
}
/**
* Every write goes through this. Never returns a connection with a plaintext credential in it: a
* secret field is left alone only when it is already a v1 envelope that opens under the current
* key (`readSecret` returns `decrypted`) - plaintext and anything merely shaped like an envelope
* are both sealed.
*/
export function encryptConnections(connections: DatabaseConnection[]): DatabaseConnection[] {
return connections.map((connection) => walkConnection(connection, sealIfPlaintext).connection);
}
/**
* A copy with every secret field removed, for a reader that may use a connection but must not
* hold its credentials: the browser, for a managed seed it opens by id
* (GET /api/connections/managed). The same maps and walker as encryption, so a field classified
* secret tomorrow is withheld here without a second list to remember. An empty value stays, as it
* does in the walker: there is nothing in it to withhold.
*/
export function withoutSecretFields<T extends DatabaseConnection>(connection: T): T {
return walkConnection(connection, () => undefined).connection as T;
}
export interface ConnectionReadResult {
connections: DatabaseConnection[];
/** How many secret fields could not be opened. The caller reports it once, not per field. */
undecryptable: number;
}
/**
* Every read goes through this. An unreadable field is OMITTED and the record kept:
*
* - Throwing would empty all twelve collections for a rotated key, taking the user's query history,
* saved queries, charts and snapshots down with the passwords.
* - Dropping the record would be worse. useStorageSync is a write-through cache, so a connection
* missing from a read is persisted as a deletion on the next push - destroying ciphertext that a
* restored key could still have opened.
*
* Omission leaves an empty password box the user can retype, and the next sync re-seals it under
* the current key.
*/
export function decryptConnections(connections: DatabaseConnection[]): ConnectionReadResult {
let undecryptable = 0;
const opened = connections.map((connection) => {
const result = walkConnection(connection, openOrDrop);
undecryptable += result.dropped;
return result.connection;
});
return { connections: opened, undecryptable };
}