@getstrata/core 2.2.11 → 2.2.13
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/view/index.d.ts +4 -2
- package/dist/core/view/viewRenderer.d.ts +24 -0
- package/dist/core/view/webLayoutData.d.ts +3 -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/framework/public-api.d.ts +4 -1
- package/dist/index.js +3102 -2705
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @getstrata/core changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.13
|
|
4
|
+
|
|
5
|
+
- Improve view rendering and better htmx support
|
|
6
|
+
|
|
7
|
+
## 2.2.12
|
|
8
|
+
|
|
9
|
+
- Require Bun >=1.4.3; align CI, generated/existing example runtime pins and Docker stages to 1.4.3, with bun-types >=1.4.3 behind the official @types/bun shim.
|
|
10
|
+
|
|
11
|
+
Add identified field-encryption keyrings, reader-first email/MFA configuration, and explicit transactional maintenance rotation for ciphertext and lookup keys with durable checkpoints, RLS scopes, fresh retirement audits and backup/rollback guidance. Fix SQLite primary-key compilation for non-generated keys. Preserve legacy auth defaults; no automatic data migration.
|
|
12
|
+
|
|
3
13
|
## 2.2.11
|
|
4
14
|
|
|
5
15
|
Add independently interruptible read-only SQLite and MySQL observations; extend bounded failed-job and outbox metrics to file-backed SQLite and URL-backed MySQL with schema/index guards, real-engine regressions and three-process consistency tests. Preserve Postgres RLS and business transaction behavior.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @getstrata/core
|
|
2
2
|
|
|
3
|
-
Runtime library for [Strata](https://github.com/EyK-26/strata) apps: HTTP, auth, database, queue, mail, and security. Requires Bun (tested on 1.4.
|
|
3
|
+
Runtime library for [Strata](https://github.com/EyK-26/strata) apps: HTTP, auth, database, queue, mail, and security. Requires Bun >=1.4.3 (tested on 1.4.3).
|
|
4
4
|
|
|
5
5
|
Generate an app rather than wiring this by hand:
|
|
6
6
|
|
|
@@ -11,3 +11,32 @@ declare function protectEmail(email: string): {
|
|
|
11
11
|
declare function revealEmail(storedEmail: string): string;
|
|
12
12
|
declare function emailLookupForQuery(email: string): string;
|
|
13
13
|
export { decryptField, emailLookupForQuery, encryptField, hashLookupValue, isFieldEncryptionEnabled, normalizeEmail, protectEmail, resolveEncryptionKey, revealEmail, };
|
|
14
|
+
interface FieldEncryptionKeyringOptions {
|
|
15
|
+
/** Public stable identifiers; never reuse an ID for different key material. */
|
|
16
|
+
activeKeyId: string;
|
|
17
|
+
encryptionKeys: Readonly<Record<string, Uint8Array>>;
|
|
18
|
+
/** Explicitly retained key for unidentified enc:v1 records and historical backups. */
|
|
19
|
+
legacyV1Key?: Uint8Array;
|
|
20
|
+
/** Pinned independently of write-key rotation. Use the original key to preserve v1 email hashes. */
|
|
21
|
+
lookupKey: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
interface FieldEncryptionKeyring {
|
|
24
|
+
readonly activeKeyId: string;
|
|
25
|
+
encrypt(plaintext: string, purpose: string): string;
|
|
26
|
+
decrypt(ciphertext: string, purpose: string): string;
|
|
27
|
+
/** Input must already be normalized using the application's existing lookup contract. */
|
|
28
|
+
lookup(normalizedValue: string): string;
|
|
29
|
+
}
|
|
30
|
+
/** Opt-in primitives only. This does not activate auth rotation or migrate persisted rows. */
|
|
31
|
+
declare function createFieldEncryptionKeyring(options: FieldEncryptionKeyringOptions): FieldEncryptionKeyring;
|
|
32
|
+
export type { FieldEncryptionKeyring, FieldEncryptionKeyringOptions };
|
|
33
|
+
export { createFieldEncryptionKeyring };
|
|
34
|
+
interface ConfiguredFieldEncryption {
|
|
35
|
+
readonly keyring: FieldEncryptionKeyring;
|
|
36
|
+
readonly writeVersion: 1 | 2;
|
|
37
|
+
encrypt(plaintext: string, purpose: string): string;
|
|
38
|
+
}
|
|
39
|
+
/** Stateless resolution keeps separately bundled auth entrypoints on the same configuration. */
|
|
40
|
+
declare function resolveFieldEncryptionKeyring(env?: Record<string, string | undefined>): ConfiguredFieldEncryption | null;
|
|
41
|
+
export type { ConfiguredFieldEncryption };
|
|
42
|
+
export { resolveFieldEncryptionKeyring };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BaseRepository } from "../database/baseRepository";
|
|
2
|
+
import type { Migration } from "../database/migrations/types";
|
|
3
|
+
import { type DatabaseDriver } from "../database/schema";
|
|
4
|
+
import { type FieldEncryptionKeyring } from "./fieldEncryption";
|
|
5
|
+
declare function createFieldEncryptionRotationMigration(name: string, driver: DatabaseDriver, options?: {
|
|
6
|
+
rls?: boolean;
|
|
7
|
+
}): Migration;
|
|
8
|
+
interface FieldEncryptionRotationOptions<TEntity extends object, PK extends keyof TEntity & string> {
|
|
9
|
+
repository: BaseRepository<TEntity, PK>;
|
|
10
|
+
/** A distinct durable run identity per table, tenant/filter scope and audit pass. */
|
|
11
|
+
runId: string;
|
|
12
|
+
scopeId: string;
|
|
13
|
+
/** Required operator assertion: all application/worker/admin writers are stopped. Not an auth grant. */
|
|
14
|
+
maintenanceMode: true;
|
|
15
|
+
source: FieldEncryptionKeyring;
|
|
16
|
+
target: FieldEncryptionKeyring;
|
|
17
|
+
fields: readonly {
|
|
18
|
+
column: Exclude<keyof TEntity & string, PK>;
|
|
19
|
+
purpose: string;
|
|
20
|
+
lookupColumn?: Exclude<keyof TEntity & string, PK>;
|
|
21
|
+
}[];
|
|
22
|
+
mode?: "rotate" | "audit";
|
|
23
|
+
batchSize?: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
interface FieldEncryptionRotationBatch {
|
|
27
|
+
processed: number;
|
|
28
|
+
changed: number;
|
|
29
|
+
completed: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** One bounded transaction. Persisted cursors advance only with committed row writes. No RLS bypass. */
|
|
32
|
+
declare function runFieldEncryptionRotationBatch<TEntity extends object, PK extends keyof TEntity & string>(options: FieldEncryptionRotationOptions<TEntity, PK>): Promise<FieldEncryptionRotationBatch>;
|
|
33
|
+
export type { FieldEncryptionRotationBatch, FieldEncryptionRotationOptions };
|
|
34
|
+
export { createFieldEncryptionRotationMigration, runFieldEncryptionRotationBatch };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
export type { LayoutDataResolver } from "./etaViewEngine";
|
|
1
|
+
export type { LayoutDataResolver, RenderOptions } from "./etaViewEngine";
|
|
2
2
|
export { DEFAULT_VIEWS_DIRECTORY, EtaViewEngine } from "./etaViewEngine";
|
|
3
3
|
export { htmlResponse, isHtmxRequest, redirectResponse, rssResponse, textResponse, xmlResponse, } from "./htmlResponse";
|
|
4
4
|
export type { ViewEngine } from "./viewEngine";
|
|
5
|
+
export type { HtmxViewResponseOptions, ViewResponseOptions } from "./viewRenderer";
|
|
6
|
+
export { CORE_VIEW_RENDERER_TOKEN, ViewRenderer } from "./viewRenderer";
|
|
5
7
|
export type { WebErrorViewInput, WebErrorViewOptions } from "./webErrorView";
|
|
6
8
|
export { configureWebErrorView, errorTemplateName, htmlErrorResponse, notFoundHtmlResponse, renderKernelErrorChrome, renderWebErrorHtml, } from "./webErrorView";
|
|
7
9
|
export type { WebLayoutAuthUser, WebLayoutData, WebLayoutDataOptions, WebLayoutUserKey, } from "./webLayoutData";
|
|
8
|
-
export { configureWebLayoutData, resolveWebLayoutData } from "./webLayoutData";
|
|
10
|
+
export { configureWebLayoutData, createWebLayoutDataResolver, resolveWebLayoutData, } from "./webLayoutData";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ViewEngine } from "./viewEngine";
|
|
2
|
+
interface ViewResponseOptions {
|
|
3
|
+
layout?: string | false;
|
|
4
|
+
request?: Request;
|
|
5
|
+
status?: number;
|
|
6
|
+
statusText?: string;
|
|
7
|
+
headers?: ConstructorParameters<typeof Headers>[0];
|
|
8
|
+
}
|
|
9
|
+
interface HtmxViewResponseOptions extends ViewResponseOptions {
|
|
10
|
+
/** Optional fragment template; otherwise render the page template without its layout. */
|
|
11
|
+
fragment?: string;
|
|
12
|
+
}
|
|
13
|
+
/** App-owned renderer; request data stays in the engine's async resolver. */
|
|
14
|
+
declare class ViewRenderer {
|
|
15
|
+
private readonly engine;
|
|
16
|
+
private readonly defaultLayout?;
|
|
17
|
+
constructor(engine: ViewEngine, defaultLayout?: string | false | undefined);
|
|
18
|
+
render(name: string, data?: Record<string, unknown>, options?: ViewResponseOptions): Promise<Response>;
|
|
19
|
+
/** Opt-in representation selection; auth/CSRF/tenancy remain the route's responsibility. */
|
|
20
|
+
renderHtmx(name: string, data?: Record<string, unknown>, options?: HtmxViewResponseOptions): Promise<Response>;
|
|
21
|
+
}
|
|
22
|
+
declare const CORE_VIEW_RENDERER_TOKEN: import("../contracts/container").ServiceToken<ViewRenderer>;
|
|
23
|
+
export type { HtmxViewResponseOptions, ViewResponseOptions };
|
|
24
|
+
export { CORE_VIEW_RENDERER_TOKEN, ViewRenderer };
|
|
@@ -24,5 +24,7 @@ interface WebLayoutDataOptions {
|
|
|
24
24
|
declare function configureWebLayoutData(options: WebLayoutDataOptions): void;
|
|
25
25
|
declare function resetWebLayoutDataConfigForTests(): void;
|
|
26
26
|
declare function resolveWebLayoutData(container: ServiceContainerLike, request?: Request, options?: WebLayoutDataOptions): Promise<Record<string, unknown>>;
|
|
27
|
+
/** Isolated configuration for one app; does not read legacy global defaults. */
|
|
28
|
+
declare function createWebLayoutDataResolver(container: ServiceContainerLike, options?: WebLayoutDataOptions): (request?: Request) => Promise<Record<string, unknown>>;
|
|
27
29
|
export type { WebLayoutAuthUser, WebLayoutData, WebLayoutDataOptions, WebLayoutUserKey };
|
|
28
|
-
export { configureWebLayoutData, resetWebLayoutDataConfigForTests, resolveWebLayoutData };
|
|
30
|
+
export { configureWebLayoutData, createWebLayoutDataResolver, resetWebLayoutDataConfigForTests, resolveWebLayoutData, };
|
|
@@ -530,7 +530,7 @@ function isFieldEncryptionEnabled() {
|
|
|
530
530
|
if (featureFlag === "true") {
|
|
531
531
|
return true;
|
|
532
532
|
}
|
|
533
|
-
return Boolean(resolveEncryptionKey());
|
|
533
|
+
return Boolean(resolveFieldEncryptionKeyring() || resolveEncryptionKey());
|
|
534
534
|
}
|
|
535
535
|
function encryptField(plaintext, key) {
|
|
536
536
|
const iv = randomBytes(IV_LENGTH);
|
|
@@ -560,6 +560,13 @@ function normalizeEmail(email) {
|
|
|
560
560
|
}
|
|
561
561
|
function protectEmail(email) {
|
|
562
562
|
const normalized = normalizeEmail(email);
|
|
563
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
564
|
+
if (configured && isFieldEncryptionEnabled()) {
|
|
565
|
+
return {
|
|
566
|
+
storedEmail: configured.encrypt(normalized, "email"),
|
|
567
|
+
emailLookup: configured.keyring.lookup(normalized)
|
|
568
|
+
};
|
|
569
|
+
}
|
|
563
570
|
const key = resolveEncryptionKey();
|
|
564
571
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
565
572
|
return { storedEmail: normalized, emailLookup: normalized };
|
|
@@ -570,6 +577,11 @@ function protectEmail(email) {
|
|
|
570
577
|
};
|
|
571
578
|
}
|
|
572
579
|
function revealEmail(storedEmail) {
|
|
580
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
581
|
+
if (configured && storedEmail.startsWith("enc:"))
|
|
582
|
+
return configured.keyring.decrypt(storedEmail, "email");
|
|
583
|
+
if (storedEmail.startsWith("enc:") && !storedEmail.startsWith(ENCRYPTION_PREFIX))
|
|
584
|
+
throw new Error("Identified encrypted fields require KMS_ENCRYPTION_KEYRING.");
|
|
573
585
|
const key = resolveEncryptionKey();
|
|
574
586
|
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
575
587
|
return storedEmail;
|
|
@@ -578,15 +590,148 @@ function revealEmail(storedEmail) {
|
|
|
578
590
|
}
|
|
579
591
|
function emailLookupForQuery(email) {
|
|
580
592
|
const normalized = normalizeEmail(email);
|
|
593
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
594
|
+
if (configured && isFieldEncryptionEnabled())
|
|
595
|
+
return configured.keyring.lookup(normalized);
|
|
581
596
|
const key = resolveEncryptionKey();
|
|
582
597
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
583
598
|
return normalized;
|
|
584
599
|
}
|
|
585
600
|
return hashLookupValue(normalized, key);
|
|
586
601
|
}
|
|
602
|
+
function createFieldEncryptionKeyring(options) {
|
|
603
|
+
const identifier = /^[A-Za-z0-9_-]{1,64}$/;
|
|
604
|
+
const copyKey = (value) => {
|
|
605
|
+
if (!(value instanceof Uint8Array) || value.byteLength !== 32)
|
|
606
|
+
throw new TypeError("Field encryption keys must be 32 bytes.");
|
|
607
|
+
return Buffer.from(value);
|
|
608
|
+
};
|
|
609
|
+
const entries = Object.entries(options.encryptionKeys);
|
|
610
|
+
if (entries.length < 1 || entries.length > 32)
|
|
611
|
+
throw new TypeError("Field encryption keyrings require 1 to 32 keys.");
|
|
612
|
+
const keys = new Map;
|
|
613
|
+
for (const [id, key] of entries) {
|
|
614
|
+
if (!identifier.test(id))
|
|
615
|
+
throw new TypeError("Invalid field encryption key identifier.");
|
|
616
|
+
keys.set(id, copyKey(key));
|
|
617
|
+
}
|
|
618
|
+
const activeKeyId = options.activeKeyId;
|
|
619
|
+
const active = keys.get(activeKeyId);
|
|
620
|
+
if (!active)
|
|
621
|
+
throw new TypeError("Active field encryption key is not retained.");
|
|
622
|
+
const legacy = options.legacyV1Key === undefined ? undefined : copyKey(options.legacyV1Key);
|
|
623
|
+
const lookup = copyKey(options.lookupKey);
|
|
624
|
+
const associatedData = (id, purpose) => {
|
|
625
|
+
if (!identifier.test(purpose))
|
|
626
|
+
throw new TypeError("Invalid field encryption purpose.");
|
|
627
|
+
return Buffer.from(`strata:field:v2:${id}:${purpose}`, "utf8");
|
|
628
|
+
};
|
|
629
|
+
const decodePayload = (encoded) => {
|
|
630
|
+
const payload = Buffer.from(encoded, "base64");
|
|
631
|
+
if (payload.length < IV_LENGTH + TAG_LENGTH || payload.toString("base64") !== encoded)
|
|
632
|
+
throw new Error("Invalid encrypted field.");
|
|
633
|
+
return payload;
|
|
634
|
+
};
|
|
635
|
+
return Object.freeze({
|
|
636
|
+
activeKeyId,
|
|
637
|
+
encrypt(plaintext, purpose) {
|
|
638
|
+
const aad = associatedData(activeKeyId, purpose);
|
|
639
|
+
const iv = randomBytes(IV_LENGTH);
|
|
640
|
+
const cipher = createCipheriv("aes-256-gcm", active, iv, { authTagLength: TAG_LENGTH });
|
|
641
|
+
cipher.setAAD(aad);
|
|
642
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
643
|
+
const payload = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString("base64");
|
|
644
|
+
return `enc:v2:${activeKeyId}:${payload}`;
|
|
645
|
+
},
|
|
646
|
+
decrypt(value, purpose) {
|
|
647
|
+
associatedData(activeKeyId, purpose);
|
|
648
|
+
try {
|
|
649
|
+
if (value.startsWith(ENCRYPTION_PREFIX)) {
|
|
650
|
+
if (!legacy)
|
|
651
|
+
throw new Error("Missing legacy key");
|
|
652
|
+
decodePayload(value.slice(ENCRYPTION_PREFIX.length));
|
|
653
|
+
return decryptField(value, legacy);
|
|
654
|
+
}
|
|
655
|
+
const parts = value.split(":");
|
|
656
|
+
if (parts.length !== 4 || parts[0] !== "enc" || parts[1] !== "v2")
|
|
657
|
+
throw new Error("Invalid envelope");
|
|
658
|
+
const id = parts[2] ?? "";
|
|
659
|
+
const key = keys.get(id);
|
|
660
|
+
if (!key)
|
|
661
|
+
throw new Error("Missing retained key");
|
|
662
|
+
const payload = decodePayload(parts[3] ?? "");
|
|
663
|
+
const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, IV_LENGTH), {
|
|
664
|
+
authTagLength: TAG_LENGTH
|
|
665
|
+
});
|
|
666
|
+
decipher.setAAD(associatedData(id, purpose));
|
|
667
|
+
decipher.setAuthTag(payload.subarray(payload.length - TAG_LENGTH));
|
|
668
|
+
return Buffer.concat([
|
|
669
|
+
decipher.update(payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH)),
|
|
670
|
+
decipher.final()
|
|
671
|
+
]).toString("utf8");
|
|
672
|
+
} catch {
|
|
673
|
+
throw new Error("Unable to decrypt encrypted field.");
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
lookup(normalizedValue) {
|
|
677
|
+
return hashLookupValue(normalizedValue, lookup);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
function resolveFieldEncryptionKeyring(env = process.env) {
|
|
682
|
+
const raw = env.KMS_ENCRYPTION_KEYRING;
|
|
683
|
+
if (raw === undefined)
|
|
684
|
+
return null;
|
|
685
|
+
try {
|
|
686
|
+
if (!raw.trim() || raw.length > 16384 || env.KMS_ENCRYPTION_KEY?.trim())
|
|
687
|
+
throw new Error("Invalid configuration");
|
|
688
|
+
const parsed = JSON.parse(raw);
|
|
689
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
690
|
+
throw new Error("Invalid configuration");
|
|
691
|
+
const config = parsed;
|
|
692
|
+
if (Object.keys(config).some((key) => !["activeKeyId", "encryptionKeys", "legacyV1Key", "lookupKey", "writeVersion"].includes(key)))
|
|
693
|
+
throw new Error("Unknown option");
|
|
694
|
+
if (typeof config.activeKeyId !== "string" || config.writeVersion !== 1 && config.writeVersion !== 2 || !config.encryptionKeys || typeof config.encryptionKeys !== "object" || Array.isArray(config.encryptionKeys))
|
|
695
|
+
throw new Error("Invalid configuration");
|
|
696
|
+
const decodeKey = (value) => {
|
|
697
|
+
if (typeof value !== "string")
|
|
698
|
+
throw new Error("Invalid key");
|
|
699
|
+
if (/^[0-9a-f]{64}$/i.test(value))
|
|
700
|
+
return Buffer.from(value, "hex");
|
|
701
|
+
const decoded = Buffer.from(value, "base64");
|
|
702
|
+
if (decoded.length !== 32 || decoded.toString("base64") !== value)
|
|
703
|
+
throw new Error("Invalid key");
|
|
704
|
+
return decoded;
|
|
705
|
+
};
|
|
706
|
+
const keys = Object.fromEntries(Object.entries(config.encryptionKeys).map(([id, value]) => [id, decodeKey(value)]));
|
|
707
|
+
const legacy = config.legacyV1Key === undefined ? undefined : decodeKey(config.legacyV1Key);
|
|
708
|
+
const lookup = decodeKey(config.lookupKey);
|
|
709
|
+
if (config.writeVersion === 1 && !legacy || legacy && !legacy.equals(lookup))
|
|
710
|
+
throw new Error("Incompatible lookup key");
|
|
711
|
+
const keyring = createFieldEncryptionKeyring({
|
|
712
|
+
activeKeyId: config.activeKeyId,
|
|
713
|
+
encryptionKeys: keys,
|
|
714
|
+
legacyV1Key: legacy,
|
|
715
|
+
lookupKey: lookup
|
|
716
|
+
});
|
|
717
|
+
const writeVersion = config.writeVersion;
|
|
718
|
+
return Object.freeze({
|
|
719
|
+
keyring,
|
|
720
|
+
writeVersion,
|
|
721
|
+
encrypt(plaintext, purpose) {
|
|
722
|
+
return writeVersion === 1 && legacy ? encryptField(plaintext, legacy) : keyring.encrypt(plaintext, purpose);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
} catch {
|
|
726
|
+
throw new Error("Invalid KMS_ENCRYPTION_KEYRING configuration.");
|
|
727
|
+
}
|
|
728
|
+
}
|
|
587
729
|
|
|
588
730
|
// ../../src/core/crypto/mfaSecret.ts
|
|
589
731
|
function protectMfaSecret(secret) {
|
|
732
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
733
|
+
if (configured)
|
|
734
|
+
return configured.encrypt(secret, "mfa");
|
|
590
735
|
const key = resolveEncryptionKey();
|
|
591
736
|
if (!key) {
|
|
592
737
|
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
@@ -597,6 +742,14 @@ function revealMfaSecret(stored) {
|
|
|
597
742
|
if (!stored) {
|
|
598
743
|
return null;
|
|
599
744
|
}
|
|
745
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
746
|
+
if (configured) {
|
|
747
|
+
if (!stored.startsWith("enc:"))
|
|
748
|
+
throw new Error("MFA secrets must be encrypted.");
|
|
749
|
+
return configured.keyring.decrypt(stored, "mfa");
|
|
750
|
+
}
|
|
751
|
+
if (stored.startsWith("enc:") && !stored.startsWith("enc:v1:"))
|
|
752
|
+
throw new Error("Identified MFA secrets require KMS_ENCRYPTION_KEYRING.");
|
|
600
753
|
if (!stored.startsWith("enc:v1:")) {
|
|
601
754
|
if (isProductionEnv() || resolveEncryptionKey()) {
|
|
602
755
|
throw new Error("MFA secrets must be encrypted.");
|
|
@@ -274,7 +274,7 @@ function isFieldEncryptionEnabled() {
|
|
|
274
274
|
if (featureFlag === "true") {
|
|
275
275
|
return true;
|
|
276
276
|
}
|
|
277
|
-
return Boolean(resolveEncryptionKey());
|
|
277
|
+
return Boolean(resolveFieldEncryptionKeyring() || resolveEncryptionKey());
|
|
278
278
|
}
|
|
279
279
|
function encryptField(plaintext, key) {
|
|
280
280
|
const iv = randomBytes(IV_LENGTH);
|
|
@@ -304,6 +304,13 @@ function normalizeEmail(email) {
|
|
|
304
304
|
}
|
|
305
305
|
function protectEmail(email) {
|
|
306
306
|
const normalized = normalizeEmail(email);
|
|
307
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
308
|
+
if (configured && isFieldEncryptionEnabled()) {
|
|
309
|
+
return {
|
|
310
|
+
storedEmail: configured.encrypt(normalized, "email"),
|
|
311
|
+
emailLookup: configured.keyring.lookup(normalized)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
307
314
|
const key = resolveEncryptionKey();
|
|
308
315
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
309
316
|
return { storedEmail: normalized, emailLookup: normalized };
|
|
@@ -314,6 +321,11 @@ function protectEmail(email) {
|
|
|
314
321
|
};
|
|
315
322
|
}
|
|
316
323
|
function revealEmail(storedEmail) {
|
|
324
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
325
|
+
if (configured && storedEmail.startsWith("enc:"))
|
|
326
|
+
return configured.keyring.decrypt(storedEmail, "email");
|
|
327
|
+
if (storedEmail.startsWith("enc:") && !storedEmail.startsWith(ENCRYPTION_PREFIX))
|
|
328
|
+
throw new Error("Identified encrypted fields require KMS_ENCRYPTION_KEYRING.");
|
|
317
329
|
const key = resolveEncryptionKey();
|
|
318
330
|
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
319
331
|
return storedEmail;
|
|
@@ -322,15 +334,148 @@ function revealEmail(storedEmail) {
|
|
|
322
334
|
}
|
|
323
335
|
function emailLookupForQuery(email) {
|
|
324
336
|
const normalized = normalizeEmail(email);
|
|
337
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
338
|
+
if (configured && isFieldEncryptionEnabled())
|
|
339
|
+
return configured.keyring.lookup(normalized);
|
|
325
340
|
const key = resolveEncryptionKey();
|
|
326
341
|
if (!key || !isFieldEncryptionEnabled()) {
|
|
327
342
|
return normalized;
|
|
328
343
|
}
|
|
329
344
|
return hashLookupValue(normalized, key);
|
|
330
345
|
}
|
|
346
|
+
function createFieldEncryptionKeyring(options) {
|
|
347
|
+
const identifier = /^[A-Za-z0-9_-]{1,64}$/;
|
|
348
|
+
const copyKey = (value) => {
|
|
349
|
+
if (!(value instanceof Uint8Array) || value.byteLength !== 32)
|
|
350
|
+
throw new TypeError("Field encryption keys must be 32 bytes.");
|
|
351
|
+
return Buffer.from(value);
|
|
352
|
+
};
|
|
353
|
+
const entries = Object.entries(options.encryptionKeys);
|
|
354
|
+
if (entries.length < 1 || entries.length > 32)
|
|
355
|
+
throw new TypeError("Field encryption keyrings require 1 to 32 keys.");
|
|
356
|
+
const keys = new Map;
|
|
357
|
+
for (const [id, key] of entries) {
|
|
358
|
+
if (!identifier.test(id))
|
|
359
|
+
throw new TypeError("Invalid field encryption key identifier.");
|
|
360
|
+
keys.set(id, copyKey(key));
|
|
361
|
+
}
|
|
362
|
+
const activeKeyId = options.activeKeyId;
|
|
363
|
+
const active = keys.get(activeKeyId);
|
|
364
|
+
if (!active)
|
|
365
|
+
throw new TypeError("Active field encryption key is not retained.");
|
|
366
|
+
const legacy = options.legacyV1Key === undefined ? undefined : copyKey(options.legacyV1Key);
|
|
367
|
+
const lookup = copyKey(options.lookupKey);
|
|
368
|
+
const associatedData = (id, purpose) => {
|
|
369
|
+
if (!identifier.test(purpose))
|
|
370
|
+
throw new TypeError("Invalid field encryption purpose.");
|
|
371
|
+
return Buffer.from(`strata:field:v2:${id}:${purpose}`, "utf8");
|
|
372
|
+
};
|
|
373
|
+
const decodePayload = (encoded) => {
|
|
374
|
+
const payload = Buffer.from(encoded, "base64");
|
|
375
|
+
if (payload.length < IV_LENGTH + TAG_LENGTH || payload.toString("base64") !== encoded)
|
|
376
|
+
throw new Error("Invalid encrypted field.");
|
|
377
|
+
return payload;
|
|
378
|
+
};
|
|
379
|
+
return Object.freeze({
|
|
380
|
+
activeKeyId,
|
|
381
|
+
encrypt(plaintext, purpose) {
|
|
382
|
+
const aad = associatedData(activeKeyId, purpose);
|
|
383
|
+
const iv = randomBytes(IV_LENGTH);
|
|
384
|
+
const cipher = createCipheriv("aes-256-gcm", active, iv, { authTagLength: TAG_LENGTH });
|
|
385
|
+
cipher.setAAD(aad);
|
|
386
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
387
|
+
const payload = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString("base64");
|
|
388
|
+
return `enc:v2:${activeKeyId}:${payload}`;
|
|
389
|
+
},
|
|
390
|
+
decrypt(value, purpose) {
|
|
391
|
+
associatedData(activeKeyId, purpose);
|
|
392
|
+
try {
|
|
393
|
+
if (value.startsWith(ENCRYPTION_PREFIX)) {
|
|
394
|
+
if (!legacy)
|
|
395
|
+
throw new Error("Missing legacy key");
|
|
396
|
+
decodePayload(value.slice(ENCRYPTION_PREFIX.length));
|
|
397
|
+
return decryptField(value, legacy);
|
|
398
|
+
}
|
|
399
|
+
const parts = value.split(":");
|
|
400
|
+
if (parts.length !== 4 || parts[0] !== "enc" || parts[1] !== "v2")
|
|
401
|
+
throw new Error("Invalid envelope");
|
|
402
|
+
const id = parts[2] ?? "";
|
|
403
|
+
const key = keys.get(id);
|
|
404
|
+
if (!key)
|
|
405
|
+
throw new Error("Missing retained key");
|
|
406
|
+
const payload = decodePayload(parts[3] ?? "");
|
|
407
|
+
const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, IV_LENGTH), {
|
|
408
|
+
authTagLength: TAG_LENGTH
|
|
409
|
+
});
|
|
410
|
+
decipher.setAAD(associatedData(id, purpose));
|
|
411
|
+
decipher.setAuthTag(payload.subarray(payload.length - TAG_LENGTH));
|
|
412
|
+
return Buffer.concat([
|
|
413
|
+
decipher.update(payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH)),
|
|
414
|
+
decipher.final()
|
|
415
|
+
]).toString("utf8");
|
|
416
|
+
} catch {
|
|
417
|
+
throw new Error("Unable to decrypt encrypted field.");
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
lookup(normalizedValue) {
|
|
421
|
+
return hashLookupValue(normalizedValue, lookup);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function resolveFieldEncryptionKeyring(env = process.env) {
|
|
426
|
+
const raw = env.KMS_ENCRYPTION_KEYRING;
|
|
427
|
+
if (raw === undefined)
|
|
428
|
+
return null;
|
|
429
|
+
try {
|
|
430
|
+
if (!raw.trim() || raw.length > 16384 || env.KMS_ENCRYPTION_KEY?.trim())
|
|
431
|
+
throw new Error("Invalid configuration");
|
|
432
|
+
const parsed = JSON.parse(raw);
|
|
433
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
434
|
+
throw new Error("Invalid configuration");
|
|
435
|
+
const config = parsed;
|
|
436
|
+
if (Object.keys(config).some((key) => !["activeKeyId", "encryptionKeys", "legacyV1Key", "lookupKey", "writeVersion"].includes(key)))
|
|
437
|
+
throw new Error("Unknown option");
|
|
438
|
+
if (typeof config.activeKeyId !== "string" || config.writeVersion !== 1 && config.writeVersion !== 2 || !config.encryptionKeys || typeof config.encryptionKeys !== "object" || Array.isArray(config.encryptionKeys))
|
|
439
|
+
throw new Error("Invalid configuration");
|
|
440
|
+
const decodeKey = (value) => {
|
|
441
|
+
if (typeof value !== "string")
|
|
442
|
+
throw new Error("Invalid key");
|
|
443
|
+
if (/^[0-9a-f]{64}$/i.test(value))
|
|
444
|
+
return Buffer.from(value, "hex");
|
|
445
|
+
const decoded = Buffer.from(value, "base64");
|
|
446
|
+
if (decoded.length !== 32 || decoded.toString("base64") !== value)
|
|
447
|
+
throw new Error("Invalid key");
|
|
448
|
+
return decoded;
|
|
449
|
+
};
|
|
450
|
+
const keys = Object.fromEntries(Object.entries(config.encryptionKeys).map(([id, value]) => [id, decodeKey(value)]));
|
|
451
|
+
const legacy = config.legacyV1Key === undefined ? undefined : decodeKey(config.legacyV1Key);
|
|
452
|
+
const lookup = decodeKey(config.lookupKey);
|
|
453
|
+
if (config.writeVersion === 1 && !legacy || legacy && !legacy.equals(lookup))
|
|
454
|
+
throw new Error("Incompatible lookup key");
|
|
455
|
+
const keyring = createFieldEncryptionKeyring({
|
|
456
|
+
activeKeyId: config.activeKeyId,
|
|
457
|
+
encryptionKeys: keys,
|
|
458
|
+
legacyV1Key: legacy,
|
|
459
|
+
lookupKey: lookup
|
|
460
|
+
});
|
|
461
|
+
const writeVersion = config.writeVersion;
|
|
462
|
+
return Object.freeze({
|
|
463
|
+
keyring,
|
|
464
|
+
writeVersion,
|
|
465
|
+
encrypt(plaintext, purpose) {
|
|
466
|
+
return writeVersion === 1 && legacy ? encryptField(plaintext, legacy) : keyring.encrypt(plaintext, purpose);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
throw new Error("Invalid KMS_ENCRYPTION_KEYRING configuration.");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
331
473
|
|
|
332
474
|
// ../../src/core/crypto/mfaSecret.ts
|
|
333
475
|
function protectMfaSecret(secret) {
|
|
476
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
477
|
+
if (configured)
|
|
478
|
+
return configured.encrypt(secret, "mfa");
|
|
334
479
|
const key = resolveEncryptionKey();
|
|
335
480
|
if (!key) {
|
|
336
481
|
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
@@ -341,6 +486,14 @@ function revealMfaSecret(stored) {
|
|
|
341
486
|
if (!stored) {
|
|
342
487
|
return null;
|
|
343
488
|
}
|
|
489
|
+
const configured = resolveFieldEncryptionKeyring();
|
|
490
|
+
if (configured) {
|
|
491
|
+
if (!stored.startsWith("enc:"))
|
|
492
|
+
throw new Error("MFA secrets must be encrypted.");
|
|
493
|
+
return configured.keyring.decrypt(stored, "mfa");
|
|
494
|
+
}
|
|
495
|
+
if (stored.startsWith("enc:") && !stored.startsWith("enc:v1:"))
|
|
496
|
+
throw new Error("Identified MFA secrets require KMS_ENCRYPTION_KEYRING.");
|
|
344
497
|
if (!stored.startsWith("enc:v1:")) {
|
|
345
498
|
if (isProductionEnv() || resolveEncryptionKey()) {
|
|
346
499
|
throw new Error("MFA secrets must be encrypted.");
|