/
index.ts
269 lines (222 loc) · 10.8 KB
/
index.ts
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
import { Client, Connection, Pool } from 'pg';
import { Socket } from '../shims/net';
import { neon, NeonDbError } from './httpQuery';
import type { NeonConfigGlobalAndClient } from './neonConfig';
// @ts-ignore -- this isn't officially exported by pg
import ConnectionParameters from '../node_modules/pg/lib/connection-parameters';
interface ConnectionParameters {
user: string;
password: string;
host: string;
database: string;
}
/**
* We export the pg library mostly unchanged, but we do make a few tweaks.
*
* (1) Connecting and querying can require a lot of network round-trips. We
* add a pipelining option for the connection (startup + auth + first query),
* but this works with cleartext password auth only. We can also pipeline TLS
* startup, but currently this works only with Neon hosts (not vanilla pg or
* pgbouncer).
*
* (2) SCRAM auth is deliberately CPU-intensive, and this is not appropriate
* for a serverless environment. In case it is still used, however, we replace
* the standard (synchronous) pg implementation with one that uses SubtleCrypto
* for repeated SHA-256 digests. This saves some time and CPU.
*
* (3) We now (experimentally) redirect Pool.query over a fetch request if the
* circumstances are right.
*/
declare interface NeonClient {
// these types suppress type errors in this file, but do not carry over to
// the npm package
connection: Connection & {
stream: Socket;
sendSCRAMClientFinalMessage: (response: any) => void;
ssl: any;
};
_handleReadyForQuery: any;
_handleAuthCleartextPassword: any;
startup: any;
getStartupConf: any;
saslSession: any;
}
class NeonClient extends Client {
get neonConfig(): NeonConfigGlobalAndClient { return this.connection.stream; }
constructor(public config: any) {
super(config);
}
connect(): Promise<void>;
connect(callback: (err?: Error) => void): void;
connect(callback?: (err?: Error) => void) {
const { neonConfig } = this;
// disable TLS if requested
if (neonConfig.forceDisablePgSSL) {
this.ssl = this.connection.ssl = false;
}
// warn on double-encryption
if (this.ssl && neonConfig.useSecureWebSocket) {
console.warn(`SSL is enabled for both Postgres (e.g. ?sslmode=require in the connection string + forceDisablePgSSL = false) and the WebSocket tunnel (useSecureWebSocket = true). Double encryption will increase latency and CPU usage. It may be appropriate to disable SSL in the Postgres connection parameters or set forceDisablePgSSL = true.`);
}
// throw on likely missing DB connection params
const hasConfiguredHost = this.config?.host !== undefined || this.config?.connectionString !== undefined || process.env.PGHOST !== undefined;
const defaultUser = process.env.USER ?? process.env.USERNAME;
if (
!hasConfiguredHost &&
this.host === 'localhost' &&
this.user === defaultUser &&
this.database === defaultUser &&
this.password === null
) throw new Error(`No database host or connection string was set, and key parameters have default values (host: localhost, user: ${defaultUser}, db: ${defaultUser}, password: null). Is an environment variable missing? Alternatively, if you intended to connect with these parameters, please set the host to 'localhost' explicitly.`);
// pipelining
const result = super.connect(callback as any) as void | Promise<void>;
const pipelineTLS = neonConfig.pipelineTLS && this.ssl;
const pipelineConnect = neonConfig.pipelineConnect === 'password';
if (!pipelineTLS && !neonConfig.pipelineConnect) return result;
const con = this.connection;
if (pipelineTLS) {
// for a pipelined SSL connection, fake the SSL support message from the
// server (the server's actual 'S' response is ignored via the
// expectPreData argument to startTls in shims / net / index.ts)
con.on('connect', () => con.stream.emit('data', 'S'));
// -> prompts call to tls.connect and immediate 'sslconnect' event
}
if (pipelineConnect) {
// for a pipelined startup:
// (1) don't respond to authenticationCleartextPassword; instead, send
// the password ahead of time
// (2) *one time only*, don't respond to readyForQuery; instead, assume
// it's already true
con.removeAllListeners('authenticationCleartextPassword');
con.removeAllListeners('readyForQuery');
con.once('readyForQuery', () => con.on('readyForQuery', this._handleReadyForQuery.bind(this)));
const connectEvent = this.ssl ? 'sslconnect' : 'connect';
con.on(connectEvent, () => {
this._handleAuthCleartextPassword();
this._handleReadyForQuery();
});
}
return result;
}
async _handleAuthSASLContinue(msg: any) {
const session = this.saslSession;
const password = this.password;
const serverData = msg.data;
if (session.message !== 'SASLInitialResponse' || typeof password !== 'string' || typeof serverData !== 'string') throw new Error('SASL: protocol error');
const attrPairs = Object.fromEntries(
serverData.split(',').map(attrValue => {
if (!/^.=/.test(attrValue)) throw new Error('SASL: Invalid attribute pair entry')
const name = attrValue[0];
const value = attrValue.substring(2);
return [name, value];
})
);
const nonce = attrPairs.r;
const salt = attrPairs.s;
const iterationText = attrPairs.i;
if (!nonce || !/^[!-+--~]+$/.test(nonce)) throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing/unprintable');
if (!salt || !/^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/.test(salt)) throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing/not base64');
if (!iterationText || !/^[1-9][0-9]*$/.test(iterationText)) throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: missing/invalid iteration count');
if (!nonce.startsWith(session.clientNonce)) throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce');
if (nonce.length === session.clientNonce.length) throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short');
const iterations = parseInt(iterationText, 10);
const saltBytes = Buffer.from(salt, 'base64');
const enc = new TextEncoder();
const passwordBytes = enc.encode(password);
const iterHmacKey = await crypto.subtle.importKey('raw', passwordBytes, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']);
let ui1 = new Uint8Array(await crypto.subtle.sign('HMAC', iterHmacKey, Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])])));
let ui = ui1;
for (var i = 0; i < iterations - 1; i++) {
ui1 = new Uint8Array(await crypto.subtle.sign('HMAC', iterHmacKey, ui1));
ui = Buffer.from(ui.map((_, i) => ui[i] ^ ui1[i]));
}
const saltedPassword = ui;
const ckHmacKey = await crypto.subtle.importKey('raw', saltedPassword, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']);
const clientKey = new Uint8Array(await crypto.subtle.sign('HMAC', ckHmacKey, enc.encode('Client Key')));
const storedKey = await crypto.subtle.digest('SHA-256', clientKey);
const clientFirstMessageBare = 'n=*,r=' + session.clientNonce;
const serverFirstMessage = 'r=' + nonce + ',s=' + salt + ',i=' + iterations;
const clientFinalMessageWithoutProof = 'c=biws,r=' + nonce;
const authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof;
const csHmacKey = await crypto.subtle.importKey('raw', storedKey, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']);
var clientSignature = new Uint8Array(await crypto.subtle.sign('HMAC', csHmacKey, enc.encode(authMessage)));
var clientProofBytes = Buffer.from(clientKey.map((_, i) => clientKey[i] ^ clientSignature[i]));
var clientProof = clientProofBytes.toString('base64');
const skHmacKey = await crypto.subtle.importKey('raw', saltedPassword, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']);
const serverKey = await crypto.subtle.sign('HMAC', skHmacKey, enc.encode('Server Key'));
const ssbHmacKey = await crypto.subtle.importKey('raw', serverKey, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']);
var serverSignatureBytes = Buffer.from(await crypto.subtle.sign('HMAC', ssbHmacKey, enc.encode(authMessage)));
session.message = 'SASLResponse';
session.serverSignature = serverSignatureBytes.toString('base64');
session.response = clientFinalMessageWithoutProof + ',p=' + clientProof;
this.connection.sendSCRAMClientFinalMessage(this.saslSession.response);
};
}
// copied from pg to support NeonPool.query
function promisify(Promise: any, callback: any) {
if (callback) return { callback: callback, result: undefined };
let rej: any, res: any;
const cb = function (err: any, client: any) { err ? rej(err) : res(client); };
const result = new Promise(function (resolve: any, reject: any) {
res = resolve;
rej = reject;
});
return { callback: cb, result: result };
}
class NeonPool extends Pool {
Client = NeonClient;
hasFetchUnsupportedListeners = false;
on(event: 'error' | 'connect' | 'acquire' | 'release' | 'remove', listener: any) {
if (event !== 'error') this.hasFetchUnsupportedListeners = true;
return super.on(event as any, listener);
}
// @ts-ignore -- is it even possible to make TS happy with these overloaded function types?
query(config?: any, values?: any, cb?: any) {
if (
!Socket.poolQueryViaFetch
|| this.hasFetchUnsupportedListeners
|| typeof config === 'function' // super.query will detect this and error
) {
return super.query(config, values, cb);
}
// allow plain text query without values
if (typeof values === 'function') {
cb = values;
values = undefined;
}
// create a synthetic callback that resolves the returned Promise
// @ts-ignore -- TS doesn't know about this.Promise
const response = promisify(this.Promise, cb)
cb = response.callback;
try {
// @ts-ignore -- TS doesn't know about this.options
const cp = new ConnectionParameters(this.options) as ConnectionParametersWithPassword;
const euc = encodeURIComponent, eu = encodeURI;
const connectionString = `postgresql://${euc(cp.user)}:${euc(cp.password)}@${euc(cp.host)}/${eu(cp.database)}`;
const queryText = typeof config === 'string' ? config : config.text;
const queryValues = values ?? config.values ?? [];
const sql = neon(connectionString, { fullResults: true, arrayMode: config.rowMode === 'array' });
sql(queryText, queryValues)
.then(result => cb(undefined, result))
.catch(err => cb(err));
} catch (err) {
cb(err);
}
return response.result;
}
}
export {
Socket as neonConfig,
NeonPool as Pool,
NeonClient as Client,
neon,
NeonDbError
};
export {
Connection,
DatabaseError,
Query,
ClientBase,
defaults,
types,
} from 'pg';