@getstrata/core 2.2.10 → 2.2.12
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/core/crypto/fieldEncryption.d.ts +29 -0
- package/dist/core/crypto/fieldEncryptionRotation.d.ts +34 -0
- package/dist/core/database/baseRepository.d.ts +5 -0
- package/dist/core/database/isolatedReadOnlyObservation.d.ts +15 -0
- package/dist/core/database/mysqlConnection.d.ts +3 -0
- package/dist/core/database/readOnlyCollector.d.ts +7 -0
- package/dist/core/database/sqliteConnection.d.ts +3 -0
- package/dist/core/events/outbox/metrics.d.ts +1 -1
- package/dist/entries/auth/basicAuthGuard.js +154 -1
- package/dist/entries/auth/passwordLogin.js +154 -1
- package/dist/entries/crypto/fieldEncryption.js +145 -1
- package/dist/entries/crypto/fieldEncryptionRotation.js +1 -0
- package/dist/entries/crypto/mfaSecret.js +154 -1
- package/dist/entries/database/schema.js +2 -2
- package/dist/entries/database/sqliteConnection.js +137 -1
- package/dist/entries/queue/queueMetrics.js +61 -27
- package/dist/framework/public-api.d.ts +2 -0
- package/dist/index.js +3178 -2601
- package/package.json +8 -3
- package/dist/core/database/postgresReadOnlyCollector.d.ts +0 -7
|
@@ -26,7 +26,7 @@ function isFieldEncryptionEnabled() {
|
|
|
26
26
|
if (featureFlag === "true") {
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
|
-
return Boolean(resolveEncryptionKey());
|
|
29
|
+
return Boolean(resolveFieldEncryptionKeyring() || resolveEncryptionKey());
|
|
30
30
|
}
|
|
31
31
|
function encryptField(plaintext, key) {
|
|
32
32
|
const iv = randomBytes(IV_LENGTH);
|
|
@@ -56,6 +56,13 @@ function normalizeEmail(email) {
|
|
|
56
56
|
}
|
|
57
57
|
function protectEmail(email) {
|
|
58
58
|
const normalized = normalizeEmail(email);
|
|
59
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
60
|
+
if (configured && isFieldEncryptionEnabled()) {
|
|
61
|
+
return {
|
|
62
|
+
storedEmail: configured.encrypt(normalized, "email"),
|
|
63
|
+
emailLookup: configured.keyring.lookup(normalized)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
59
66
|
const key = resolveEncryptionKey();
|
|
60
67
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
61
68
|
return { storedEmail: normalized, emailLookup: normalized };
|
|
@@ -66,6 +73,11 @@ function protectEmail(email) {
|
|
|
66
73
|
};
|
|
67
74
|
}
|
|
68
75
|
function revealEmail(storedEmail) {
|
|
76
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
77
|
+
if (configured && storedEmail.startsWith("enc:"))
|
|
78
|
+
return configured.keyring.decrypt(storedEmail, "email");
|
|
79
|
+
if (storedEmail.startsWith("enc:") && !storedEmail.startsWith(ENCRYPTION_PREFIX))
|
|
80
|
+
throw new Error("Identified encrypted fields require KMS_ENCRYPTION_KEYRING.");
|
|
69
81
|
const key = resolveEncryptionKey();
|
|
70
82
|
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
71
83
|
return storedEmail;
|
|
@@ -74,13 +86,144 @@ function revealEmail(storedEmail) {
|
|
|
74
86
|
}
|
|
75
87
|
function emailLookupForQuery(email) {
|
|
76
88
|
const normalized = normalizeEmail(email);
|
|
89
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
90
|
+
if (configured && isFieldEncryptionEnabled())
|
|
91
|
+
return configured.keyring.lookup(normalized);
|
|
77
92
|
const key = resolveEncryptionKey();
|
|
78
93
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
79
94
|
return normalized;
|
|
80
95
|
}
|
|
81
96
|
return hashLookupValue(normalized, key);
|
|
82
97
|
}
|
|
98
|
+
function createFieldEncryptionKeyring(options) {
|
|
99
|
+
const identifier = /^[A-Za-z0-9_-]{1,64}$/;
|
|
100
|
+
const copyKey = (value) => {
|
|
101
|
+
if (!(value instanceof Uint8Array) || value.byteLength !== 32)
|
|
102
|
+
throw new TypeError("Field encryption keys must be 32 bytes.");
|
|
103
|
+
return Buffer.from(value);
|
|
104
|
+
};
|
|
105
|
+
const entries = Object.entries(options.encryptionKeys);
|
|
106
|
+
if (entries.length < 1 || entries.length > 32)
|
|
107
|
+
throw new TypeError("Field encryption keyrings require 1 to 32 keys.");
|
|
108
|
+
const keys = new Map;
|
|
109
|
+
for (const [id, key] of entries) {
|
|
110
|
+
if (!identifier.test(id))
|
|
111
|
+
throw new TypeError("Invalid field encryption key identifier.");
|
|
112
|
+
keys.set(id, copyKey(key));
|
|
113
|
+
}
|
|
114
|
+
const activeKeyId = options.activeKeyId;
|
|
115
|
+
const active = keys.get(activeKeyId);
|
|
116
|
+
if (!active)
|
|
117
|
+
throw new TypeError("Active field encryption key is not retained.");
|
|
118
|
+
const legacy = options.legacyV1Key === undefined ? undefined : copyKey(options.legacyV1Key);
|
|
119
|
+
const lookup = copyKey(options.lookupKey);
|
|
120
|
+
const associatedData = (id, purpose) => {
|
|
121
|
+
if (!identifier.test(purpose))
|
|
122
|
+
throw new TypeError("Invalid field encryption purpose.");
|
|
123
|
+
return Buffer.from(`strata:field:v2:${id}:${purpose}`, "utf8");
|
|
124
|
+
};
|
|
125
|
+
const decodePayload = (encoded) => {
|
|
126
|
+
const payload = Buffer.from(encoded, "base64");
|
|
127
|
+
if (payload.length < IV_LENGTH + TAG_LENGTH || payload.toString("base64") !== encoded)
|
|
128
|
+
throw new Error("Invalid encrypted field.");
|
|
129
|
+
return payload;
|
|
130
|
+
};
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
activeKeyId,
|
|
133
|
+
encrypt(plaintext, purpose) {
|
|
134
|
+
const aad = associatedData(activeKeyId, purpose);
|
|
135
|
+
const iv = randomBytes(IV_LENGTH);
|
|
136
|
+
const cipher = createCipheriv("aes-256-gcm", active, iv, { authTagLength: TAG_LENGTH });
|
|
137
|
+
cipher.setAAD(aad);
|
|
138
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
139
|
+
const payload = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString("base64");
|
|
140
|
+
return `enc:v2:${activeKeyId}:${payload}`;
|
|
141
|
+
},
|
|
142
|
+
decrypt(value, purpose) {
|
|
143
|
+
associatedData(activeKeyId, purpose);
|
|
144
|
+
try {
|
|
145
|
+
if (value.startsWith(ENCRYPTION_PREFIX)) {
|
|
146
|
+
if (!legacy)
|
|
147
|
+
throw new Error("Missing legacy key");
|
|
148
|
+
decodePayload(value.slice(ENCRYPTION_PREFIX.length));
|
|
149
|
+
return decryptField(value, legacy);
|
|
150
|
+
}
|
|
151
|
+
const parts = value.split(":");
|
|
152
|
+
if (parts.length !== 4 || parts[0] !== "enc" || parts[1] !== "v2")
|
|
153
|
+
throw new Error("Invalid envelope");
|
|
154
|
+
const id = parts[2] ?? "";
|
|
155
|
+
const key = keys.get(id);
|
|
156
|
+
if (!key)
|
|
157
|
+
throw new Error("Missing retained key");
|
|
158
|
+
const payload = decodePayload(parts[3] ?? "");
|
|
159
|
+
const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, IV_LENGTH), {
|
|
160
|
+
authTagLength: TAG_LENGTH
|
|
161
|
+
});
|
|
162
|
+
decipher.setAAD(associatedData(id, purpose));
|
|
163
|
+
decipher.setAuthTag(payload.subarray(payload.length - TAG_LENGTH));
|
|
164
|
+
return Buffer.concat([
|
|
165
|
+
decipher.update(payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH)),
|
|
166
|
+
decipher.final()
|
|
167
|
+
]).toString("utf8");
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error("Unable to decrypt encrypted field.");
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
lookup(normalizedValue) {
|
|
173
|
+
return hashLookupValue(normalizedValue, lookup);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function resolveFieldEncryptionKeyring(env = process.env) {
|
|
178
|
+
const raw = env.KMS_ENCRYPTION_KEYRING;
|
|
179
|
+
if (raw === undefined)
|
|
180
|
+
return null;
|
|
181
|
+
try {
|
|
182
|
+
if (!raw.trim() || raw.length > 16384 || env.KMS_ENCRYPTION_KEY?.trim())
|
|
183
|
+
throw new Error("Invalid configuration");
|
|
184
|
+
const parsed = JSON.parse(raw);
|
|
185
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
186
|
+
throw new Error("Invalid configuration");
|
|
187
|
+
const config = parsed;
|
|
188
|
+
if (Object.keys(config).some((key) => !["activeKeyId", "encryptionKeys", "legacyV1Key", "lookupKey", "writeVersion"].includes(key)))
|
|
189
|
+
throw new Error("Unknown option");
|
|
190
|
+
if (typeof config.activeKeyId !== "string" || config.writeVersion !== 1 && config.writeVersion !== 2 || !config.encryptionKeys || typeof config.encryptionKeys !== "object" || Array.isArray(config.encryptionKeys))
|
|
191
|
+
throw new Error("Invalid configuration");
|
|
192
|
+
const decodeKey = (value) => {
|
|
193
|
+
if (typeof value !== "string")
|
|
194
|
+
throw new Error("Invalid key");
|
|
195
|
+
if (/^[0-9a-f]{64}$/i.test(value))
|
|
196
|
+
return Buffer.from(value, "hex");
|
|
197
|
+
const decoded = Buffer.from(value, "base64");
|
|
198
|
+
if (decoded.length !== 32 || decoded.toString("base64") !== value)
|
|
199
|
+
throw new Error("Invalid key");
|
|
200
|
+
return decoded;
|
|
201
|
+
};
|
|
202
|
+
const keys = Object.fromEntries(Object.entries(config.encryptionKeys).map(([id, value]) => [id, decodeKey(value)]));
|
|
203
|
+
const legacy = config.legacyV1Key === undefined ? undefined : decodeKey(config.legacyV1Key);
|
|
204
|
+
const lookup = decodeKey(config.lookupKey);
|
|
205
|
+
if (config.writeVersion === 1 && !legacy || legacy && !legacy.equals(lookup))
|
|
206
|
+
throw new Error("Incompatible lookup key");
|
|
207
|
+
const keyring = createFieldEncryptionKeyring({
|
|
208
|
+
activeKeyId: config.activeKeyId,
|
|
209
|
+
encryptionKeys: keys,
|
|
210
|
+
legacyV1Key: legacy,
|
|
211
|
+
lookupKey: lookup
|
|
212
|
+
});
|
|
213
|
+
const writeVersion = config.writeVersion;
|
|
214
|
+
return Object.freeze({
|
|
215
|
+
keyring,
|
|
216
|
+
writeVersion,
|
|
217
|
+
encrypt(plaintext, purpose) {
|
|
218
|
+
return writeVersion === 1 && legacy ? encryptField(plaintext, legacy) : keyring.encrypt(plaintext, purpose);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error("Invalid KMS_ENCRYPTION_KEYRING configuration.");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
83
225
|
export {
|
|
226
|
+
createFieldEncryptionKeyring,
|
|
84
227
|
decryptField,
|
|
85
228
|
emailLookupForQuery,
|
|
86
229
|
encryptField,
|
|
@@ -89,5 +232,6 @@ export {
|
|
|
89
232
|
normalizeEmail,
|
|
90
233
|
protectEmail,
|
|
91
234
|
resolveEncryptionKey,
|
|
235
|
+
resolveFieldEncryptionKeyring,
|
|
92
236
|
revealEmail
|
|
93
237
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../../index.js";
|
|
@@ -46,7 +46,7 @@ function isFieldEncryptionEnabled() {
|
|
|
46
46
|
if (featureFlag === "true") {
|
|
47
47
|
return true;
|
|
48
48
|
}
|
|
49
|
-
return Boolean(resolveEncryptionKey());
|
|
49
|
+
return Boolean(resolveFieldEncryptionKeyring() || resolveEncryptionKey());
|
|
50
50
|
}
|
|
51
51
|
function encryptField(plaintext, key) {
|
|
52
52
|
const iv = randomBytes(IV_LENGTH);
|
|
@@ -76,6 +76,13 @@ function normalizeEmail(email) {
|
|
|
76
76
|
}
|
|
77
77
|
function protectEmail(email) {
|
|
78
78
|
const normalized = normalizeEmail(email);
|
|
79
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
80
|
+
if (configured && isFieldEncryptionEnabled()) {
|
|
81
|
+
return {
|
|
82
|
+
storedEmail: configured.encrypt(normalized, "email"),
|
|
83
|
+
emailLookup: configured.keyring.lookup(normalized)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
79
86
|
const key = resolveEncryptionKey();
|
|
80
87
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
81
88
|
return { storedEmail: normalized, emailLookup: normalized };
|
|
@@ -86,6 +93,11 @@ function protectEmail(email) {
|
|
|
86
93
|
};
|
|
87
94
|
}
|
|
88
95
|
function revealEmail(storedEmail) {
|
|
96
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
97
|
+
if (configured && storedEmail.startsWith("enc:"))
|
|
98
|
+
return configured.keyring.decrypt(storedEmail, "email");
|
|
99
|
+
if (storedEmail.startsWith("enc:") && !storedEmail.startsWith(ENCRYPTION_PREFIX))
|
|
100
|
+
throw new Error("Identified encrypted fields require KMS_ENCRYPTION_KEYRING.");
|
|
89
101
|
const key = resolveEncryptionKey();
|
|
90
102
|
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
91
103
|
return storedEmail;
|
|
@@ -94,15 +106,148 @@ function revealEmail(storedEmail) {
|
|
|
94
106
|
}
|
|
95
107
|
function emailLookupForQuery(email) {
|
|
96
108
|
const normalized = normalizeEmail(email);
|
|
109
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
110
|
+
if (configured && isFieldEncryptionEnabled())
|
|
111
|
+
return configured.keyring.lookup(normalized);
|
|
97
112
|
const key = resolveEncryptionKey();
|
|
98
113
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
99
114
|
return normalized;
|
|
100
115
|
}
|
|
101
116
|
return hashLookupValue(normalized, key);
|
|
102
117
|
}
|
|
118
|
+
function createFieldEncryptionKeyring(options) {
|
|
119
|
+
const identifier = /^[A-Za-z0-9_-]{1,64}$/;
|
|
120
|
+
const copyKey = (value) => {
|
|
121
|
+
if (!(value instanceof Uint8Array) || value.byteLength !== 32)
|
|
122
|
+
throw new TypeError("Field encryption keys must be 32 bytes.");
|
|
123
|
+
return Buffer.from(value);
|
|
124
|
+
};
|
|
125
|
+
const entries = Object.entries(options.encryptionKeys);
|
|
126
|
+
if (entries.length < 1 || entries.length > 32)
|
|
127
|
+
throw new TypeError("Field encryption keyrings require 1 to 32 keys.");
|
|
128
|
+
const keys = new Map;
|
|
129
|
+
for (const [id, key] of entries) {
|
|
130
|
+
if (!identifier.test(id))
|
|
131
|
+
throw new TypeError("Invalid field encryption key identifier.");
|
|
132
|
+
keys.set(id, copyKey(key));
|
|
133
|
+
}
|
|
134
|
+
const activeKeyId = options.activeKeyId;
|
|
135
|
+
const active = keys.get(activeKeyId);
|
|
136
|
+
if (!active)
|
|
137
|
+
throw new TypeError("Active field encryption key is not retained.");
|
|
138
|
+
const legacy = options.legacyV1Key === undefined ? undefined : copyKey(options.legacyV1Key);
|
|
139
|
+
const lookup = copyKey(options.lookupKey);
|
|
140
|
+
const associatedData = (id, purpose) => {
|
|
141
|
+
if (!identifier.test(purpose))
|
|
142
|
+
throw new TypeError("Invalid field encryption purpose.");
|
|
143
|
+
return Buffer.from(`strata:field:v2:${id}:${purpose}`, "utf8");
|
|
144
|
+
};
|
|
145
|
+
const decodePayload = (encoded) => {
|
|
146
|
+
const payload = Buffer.from(encoded, "base64");
|
|
147
|
+
if (payload.length < IV_LENGTH + TAG_LENGTH || payload.toString("base64") !== encoded)
|
|
148
|
+
throw new Error("Invalid encrypted field.");
|
|
149
|
+
return payload;
|
|
150
|
+
};
|
|
151
|
+
return Object.freeze({
|
|
152
|
+
activeKeyId,
|
|
153
|
+
encrypt(plaintext, purpose) {
|
|
154
|
+
const aad = associatedData(activeKeyId, purpose);
|
|
155
|
+
const iv = randomBytes(IV_LENGTH);
|
|
156
|
+
const cipher = createCipheriv("aes-256-gcm", active, iv, { authTagLength: TAG_LENGTH });
|
|
157
|
+
cipher.setAAD(aad);
|
|
158
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
159
|
+
const payload = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString("base64");
|
|
160
|
+
return `enc:v2:${activeKeyId}:${payload}`;
|
|
161
|
+
},
|
|
162
|
+
decrypt(value, purpose) {
|
|
163
|
+
associatedData(activeKeyId, purpose);
|
|
164
|
+
try {
|
|
165
|
+
if (value.startsWith(ENCRYPTION_PREFIX)) {
|
|
166
|
+
if (!legacy)
|
|
167
|
+
throw new Error("Missing legacy key");
|
|
168
|
+
decodePayload(value.slice(ENCRYPTION_PREFIX.length));
|
|
169
|
+
return decryptField(value, legacy);
|
|
170
|
+
}
|
|
171
|
+
const parts = value.split(":");
|
|
172
|
+
if (parts.length !== 4 || parts[0] !== "enc" || parts[1] !== "v2")
|
|
173
|
+
throw new Error("Invalid envelope");
|
|
174
|
+
const id = parts[2] ?? "";
|
|
175
|
+
const key = keys.get(id);
|
|
176
|
+
if (!key)
|
|
177
|
+
throw new Error("Missing retained key");
|
|
178
|
+
const payload = decodePayload(parts[3] ?? "");
|
|
179
|
+
const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, IV_LENGTH), {
|
|
180
|
+
authTagLength: TAG_LENGTH
|
|
181
|
+
});
|
|
182
|
+
decipher.setAAD(associatedData(id, purpose));
|
|
183
|
+
decipher.setAuthTag(payload.subarray(payload.length - TAG_LENGTH));
|
|
184
|
+
return Buffer.concat([
|
|
185
|
+
decipher.update(payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH)),
|
|
186
|
+
decipher.final()
|
|
187
|
+
]).toString("utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error("Unable to decrypt encrypted field.");
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
lookup(normalizedValue) {
|
|
193
|
+
return hashLookupValue(normalizedValue, lookup);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function resolveFieldEncryptionKeyring(env = process.env) {
|
|
198
|
+
const raw = env.KMS_ENCRYPTION_KEYRING;
|
|
199
|
+
if (raw === undefined)
|
|
200
|
+
return null;
|
|
201
|
+
try {
|
|
202
|
+
if (!raw.trim() || raw.length > 16384 || env.KMS_ENCRYPTION_KEY?.trim())
|
|
203
|
+
throw new Error("Invalid configuration");
|
|
204
|
+
const parsed = JSON.parse(raw);
|
|
205
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
206
|
+
throw new Error("Invalid configuration");
|
|
207
|
+
const config = parsed;
|
|
208
|
+
if (Object.keys(config).some((key) => !["activeKeyId", "encryptionKeys", "legacyV1Key", "lookupKey", "writeVersion"].includes(key)))
|
|
209
|
+
throw new Error("Unknown option");
|
|
210
|
+
if (typeof config.activeKeyId !== "string" || config.writeVersion !== 1 && config.writeVersion !== 2 || !config.encryptionKeys || typeof config.encryptionKeys !== "object" || Array.isArray(config.encryptionKeys))
|
|
211
|
+
throw new Error("Invalid configuration");
|
|
212
|
+
const decodeKey = (value) => {
|
|
213
|
+
if (typeof value !== "string")
|
|
214
|
+
throw new Error("Invalid key");
|
|
215
|
+
if (/^[0-9a-f]{64}$/i.test(value))
|
|
216
|
+
return Buffer.from(value, "hex");
|
|
217
|
+
const decoded = Buffer.from(value, "base64");
|
|
218
|
+
if (decoded.length !== 32 || decoded.toString("base64") !== value)
|
|
219
|
+
throw new Error("Invalid key");
|
|
220
|
+
return decoded;
|
|
221
|
+
};
|
|
222
|
+
const keys = Object.fromEntries(Object.entries(config.encryptionKeys).map(([id, value]) => [id, decodeKey(value)]));
|
|
223
|
+
const legacy = config.legacyV1Key === undefined ? undefined : decodeKey(config.legacyV1Key);
|
|
224
|
+
const lookup = decodeKey(config.lookupKey);
|
|
225
|
+
if (config.writeVersion === 1 && !legacy || legacy && !legacy.equals(lookup))
|
|
226
|
+
throw new Error("Incompatible lookup key");
|
|
227
|
+
const keyring = createFieldEncryptionKeyring({
|
|
228
|
+
activeKeyId: config.activeKeyId,
|
|
229
|
+
encryptionKeys: keys,
|
|
230
|
+
legacyV1Key: legacy,
|
|
231
|
+
lookupKey: lookup
|
|
232
|
+
});
|
|
233
|
+
const writeVersion = config.writeVersion;
|
|
234
|
+
return Object.freeze({
|
|
235
|
+
keyring,
|
|
236
|
+
writeVersion,
|
|
237
|
+
encrypt(plaintext, purpose) {
|
|
238
|
+
return writeVersion === 1 && legacy ? encryptField(plaintext, legacy) : keyring.encrypt(plaintext, purpose);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error("Invalid KMS_ENCRYPTION_KEYRING configuration.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
103
245
|
|
|
104
246
|
// ../../src/core/crypto/mfaSecret.ts
|
|
105
247
|
function protectMfaSecret(secret) {
|
|
248
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
249
|
+
if (configured)
|
|
250
|
+
return configured.encrypt(secret, "mfa");
|
|
106
251
|
const key = resolveEncryptionKey();
|
|
107
252
|
if (!key) {
|
|
108
253
|
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
@@ -113,6 +258,14 @@ function revealMfaSecret(stored) {
|
|
|
113
258
|
if (!stored) {
|
|
114
259
|
return null;
|
|
115
260
|
}
|
|
261
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
262
|
+
if (configured) {
|
|
263
|
+
if (!stored.startsWith("enc:"))
|
|
264
|
+
throw new Error("MFA secrets must be encrypted.");
|
|
265
|
+
return configured.keyring.decrypt(stored, "mfa");
|
|
266
|
+
}
|
|
267
|
+
if (stored.startsWith("enc:") && !stored.startsWith("enc:v1:"))
|
|
268
|
+
throw new Error("Identified MFA secrets require KMS_ENCRYPTION_KEYRING.");
|
|
116
269
|
if (!stored.startsWith("enc:v1:")) {
|
|
117
270
|
if (isProductionEnv() || resolveEncryptionKey()) {
|
|
118
271
|
throw new Error("MFA secrets must be encrypted.");
|
|
@@ -694,10 +694,10 @@ function compileColumn(driver, column, mode) {
|
|
|
694
694
|
parts[1] = `${parts[1]} AUTO_INCREMENT`;
|
|
695
695
|
}
|
|
696
696
|
if (column.isPrimary && mode === "create") {
|
|
697
|
-
if (driver === "sqlite") {
|
|
697
|
+
if (driver === "sqlite" && column.autoIncrement) {
|
|
698
698
|
parts.push("PRIMARY KEY AUTOINCREMENT");
|
|
699
699
|
} else {
|
|
700
|
-
parts.push("PRIMARY KEY");
|
|
700
|
+
parts.push(driver === "sqlite" && !column.isNullable ? "PRIMARY KEY NOT NULL" : "PRIMARY KEY");
|
|
701
701
|
}
|
|
702
702
|
} else if (!column.isNullable) {
|
|
703
703
|
parts.push("NOT NULL");
|
|
@@ -45,6 +45,126 @@ var init_connectionContext = __esm(() => {
|
|
|
45
45
|
// ../../src/core/database/sqliteConnection.ts
|
|
46
46
|
init_connectionContext();
|
|
47
47
|
import { Database } from "bun:sqlite";
|
|
48
|
+
import { resolve } from "path";
|
|
49
|
+
|
|
50
|
+
// ../../src/core/database/isolatedReadOnlyObservation.ts
|
|
51
|
+
var childSource = `
|
|
52
|
+
import { Database } from 'bun:sqlite';
|
|
53
|
+
let db;
|
|
54
|
+
let mysql;
|
|
55
|
+
process.on('message', async message => {
|
|
56
|
+
try {
|
|
57
|
+
if (message.init) {
|
|
58
|
+
if (message.driver === 'mysql') {
|
|
59
|
+
const module = await import(message.module);
|
|
60
|
+
mysql = await module.createConnection({ uri: message.url, timezone: 'Z', connectTimeout: message.timeoutMs });
|
|
61
|
+
await mysql.query("SET time_zone = '+00:00'");
|
|
62
|
+
await mysql.query('SET SESSION max_execution_time = ' + message.timeoutMs);
|
|
63
|
+
await mysql.query('SET SESSION lock_wait_timeout = 1');
|
|
64
|
+
await mysql.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
|
|
65
|
+
await mysql.query('START TRANSACTION READ ONLY');
|
|
66
|
+
} else {
|
|
67
|
+
db = new Database(message.filename, { readonly: true, create: false });
|
|
68
|
+
db.exec('PRAGMA busy_timeout = 0');
|
|
69
|
+
db.exec('PRAGMA query_only = ON');
|
|
70
|
+
db.exec('BEGIN');
|
|
71
|
+
}
|
|
72
|
+
process.send({ ok: true, rows: [] });
|
|
73
|
+
} else {
|
|
74
|
+
const rows = mysql ? (message.params.length ? await mysql.execute(message.query, message.params) : await mysql.query(message.query))[0] : db.query(message.query).all(...message.params);
|
|
75
|
+
process.send({ ok: true, rows });
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// No SQL, file paths, payloads or driver exception text crosses the error channel.
|
|
79
|
+
process.send({ ok: false });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
`;
|
|
83
|
+
async function observeIsolatedReadOnly(profile, operation, options) {
|
|
84
|
+
options.signal.throwIfAborted();
|
|
85
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 5000)
|
|
86
|
+
throw new TypeError("SQL observation timeout must be between 1 and 5000 milliseconds.");
|
|
87
|
+
const executable = Bun.which("bun", { PATH: process.env.PATH ?? "" });
|
|
88
|
+
if (!executable)
|
|
89
|
+
throw new Error("SQL observations require the Bun CLI on PATH.");
|
|
90
|
+
let pending;
|
|
91
|
+
let stopped = false;
|
|
92
|
+
const { promise: aborted, reject: rejectAbort } = Promise.withResolvers();
|
|
93
|
+
const fail = (error) => {
|
|
94
|
+
pending?.reject(error);
|
|
95
|
+
pending = undefined;
|
|
96
|
+
};
|
|
97
|
+
const child = Bun.spawn({
|
|
98
|
+
cmd: [
|
|
99
|
+
executable,
|
|
100
|
+
"--no-env-file",
|
|
101
|
+
`--config=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
|
|
102
|
+
"-e",
|
|
103
|
+
childSource
|
|
104
|
+
],
|
|
105
|
+
env: {},
|
|
106
|
+
stdout: "ignore",
|
|
107
|
+
stderr: "ignore",
|
|
108
|
+
serialization: "advanced",
|
|
109
|
+
ipc(message) {
|
|
110
|
+
const response = message;
|
|
111
|
+
const request = pending;
|
|
112
|
+
pending = undefined;
|
|
113
|
+
if (response?.ok === true && Array.isArray(response.rows))
|
|
114
|
+
request?.resolve(response.rows);
|
|
115
|
+
else
|
|
116
|
+
request?.reject(new Error("Read-only SQL observation failed."));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const exited = child.exited.then(() => {
|
|
120
|
+
fail(new Error("SQL observation process exited."));
|
|
121
|
+
});
|
|
122
|
+
const abort = (reason) => {
|
|
123
|
+
stopped = true;
|
|
124
|
+
child.kill("SIGKILL");
|
|
125
|
+
fail(reason);
|
|
126
|
+
rejectAbort(reason);
|
|
127
|
+
};
|
|
128
|
+
const onAbort = () => abort(options.signal.reason);
|
|
129
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
const timer = setTimeout(() => abort(new Error("SQL observation timed out.")), options.timeoutMs);
|
|
131
|
+
const request = (message) => {
|
|
132
|
+
if (stopped)
|
|
133
|
+
return Promise.reject(new Error("SQL observation is closed."));
|
|
134
|
+
if (pending)
|
|
135
|
+
return Promise.reject(new Error("Concurrent SQL observation queries are not supported."));
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
pending = { resolve, reject };
|
|
138
|
+
child.send(message);
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
if (options.signal.aborted)
|
|
143
|
+
onAbort();
|
|
144
|
+
const work = (async () => {
|
|
145
|
+
await request({ init: true, ...profile, timeoutMs: options.timeoutMs });
|
|
146
|
+
options.signal.throwIfAborted();
|
|
147
|
+
return operation({
|
|
148
|
+
async unsafe(query, params = []) {
|
|
149
|
+
return await request({
|
|
150
|
+
query,
|
|
151
|
+
params: params.map((value) => profile.driver === "sqlite" && value instanceof Date ? value.toISOString() : value)
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
})();
|
|
156
|
+
return await Promise.race([work, aborted]);
|
|
157
|
+
} finally {
|
|
158
|
+
stopped = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
161
|
+
child.kill("SIGKILL");
|
|
162
|
+
fail(new Error("SQL observation is closed."));
|
|
163
|
+
await exited;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ../../src/core/database/sqliteConnection.ts
|
|
48
168
|
function isRowReturning(sql) {
|
|
49
169
|
const upper = sql.replace(/\s+/g, " ").trim().toUpperCase();
|
|
50
170
|
if (upper.includes(" RETURNING ")) {
|
|
@@ -56,7 +176,8 @@ function createSqliteConnection(filename) {
|
|
|
56
176
|
if (!filename.trim()) {
|
|
57
177
|
throw new Error("SQLite path is not configured. Pass a filename or :memory:.");
|
|
58
178
|
}
|
|
59
|
-
const
|
|
179
|
+
const absoluteFilename = filename === ":memory:" ? filename : resolve(filename);
|
|
180
|
+
const db = new Database(absoluteFilename, { create: true });
|
|
60
181
|
db.exec("PRAGMA foreign_keys = ON");
|
|
61
182
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
62
183
|
if (filename !== ":memory:") {
|
|
@@ -119,6 +240,21 @@ function createSqliteConnection(filename) {
|
|
|
119
240
|
}
|
|
120
241
|
});
|
|
121
242
|
},
|
|
243
|
+
async observeReadOnly(operation, options) {
|
|
244
|
+
if (closed)
|
|
245
|
+
throw new Error("SQLite connection is closed.");
|
|
246
|
+
if (absoluteFilename === ":memory:")
|
|
247
|
+
throw new Error("SQL observations require file-backed SQLite.");
|
|
248
|
+
pending++;
|
|
249
|
+
try {
|
|
250
|
+
return await observeIsolatedReadOnly({ driver: "sqlite", filename: absoluteFilename }, async (transaction) => {
|
|
251
|
+
transactions.add(transaction);
|
|
252
|
+
return await runWithDatabaseConnection(transaction, () => operation(transaction));
|
|
253
|
+
}, options);
|
|
254
|
+
} finally {
|
|
255
|
+
pending--;
|
|
256
|
+
}
|
|
257
|
+
},
|
|
122
258
|
close() {
|
|
123
259
|
if (pending > 0)
|
|
124
260
|
throw new Error("Drain SQLite operations before closing the connection.");
|