@sanoma-ai/workflows 0.10.1 → 0.10.2

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,266 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { Compute, OAuth2Client } from "google-auth-library";
3
+ import type { Db } from "./db.ts";
4
+
5
+ const LIFETIME = 600_000;
6
+ const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
7
+ const CREDENTIAL = /^[A-Za-z0-9_-]{43}$/;
8
+ const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
9
+
10
+ /** A safe error boundary: no HTTP response, credentials, SDK cause or request options. */
11
+ export class CloudIdentityError extends Error {
12
+ constructor() {
13
+ super("Cloud identity unavailable");
14
+ this.name = "CloudIdentityError";
15
+ }
16
+ }
17
+
18
+ /** Provisioning only. Deliver the value once through deployment secrets; persist only its hash. */
19
+ export function createDeploymentCredential(): { credential: string; hash: string } {
20
+ const credential = randomBytes(32).toString("base64url");
21
+ return { credential, hash: credentialHash(credential) };
22
+ }
23
+ const credentialHash = (credential: string) => createHash("sha256").update(credential).digest("hex");
24
+
25
+ export interface IdentityEnrollment {
26
+ deployment: string;
27
+ company: string;
28
+ environment: string;
29
+ serviceAccount: string;
30
+ generation: number;
31
+ }
32
+ export interface IdentityEnrollments {
33
+ /** Return an enrollment only while this hash/generation and deployment are active. Never cache. */
34
+ active(hash: string): Promise<IdentityEnrollment | undefined>;
35
+ }
36
+
37
+ /** Install in the broker's own database using provisioning authority, never a company database. */
38
+ export const CLOUD_IDENTITY_SQL = `
39
+ CREATE SCHEMA IF NOT EXISTS sanoma_identity;
40
+ REVOKE ALL ON SCHEMA sanoma_identity FROM PUBLIC;
41
+ CREATE TABLE IF NOT EXISTS sanoma_identity.deployments (
42
+ id text PRIMARY KEY, company text NOT NULL, environment text NOT NULL,
43
+ service_account text NOT NULL, enabled boolean NOT NULL DEFAULT true
44
+ );
45
+ CREATE TABLE IF NOT EXISTS sanoma_identity.credentials (
46
+ hash text PRIMARY KEY CHECK (hash ~ '^[0-9a-f]{64}$'),
47
+ deployment text NOT NULL REFERENCES sanoma_identity.deployments(id),
48
+ generation bigint NOT NULL CHECK (generation > 0),
49
+ valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz NOT NULL,
50
+ revoked boolean NOT NULL DEFAULT false,
51
+ UNIQUE (deployment, generation), CHECK (valid_until > valid_from),
52
+ CHECK (isfinite(valid_from) AND isfinite(valid_until))
53
+ );
54
+ REVOKE ALL ON ALL TABLES IN SCHEMA sanoma_identity FROM PUBLIC;
55
+ `;
56
+
57
+ /** Give the broker SELECT only; enrollment, rotation and revocation use a separate database role. */
58
+ export function postgresIdentityEnrollments(database: () => Promise<Db>): IdentityEnrollments {
59
+ return {
60
+ async active(hash) {
61
+ const { rows } = await (
62
+ await database()
63
+ ).query<{
64
+ deployment: string;
65
+ company: string;
66
+ environment: string;
67
+ service_account: string;
68
+ generation: string;
69
+ }>(
70
+ `SELECT d.id AS deployment, d.company, d.environment, d.service_account, c.generation
71
+ FROM sanoma_identity.credentials c JOIN sanoma_identity.deployments d ON d.id = c.deployment
72
+ WHERE c.hash = $1 AND NOT c.revoked AND d.enabled
73
+ AND c.valid_from <= clock_timestamp() AND c.valid_until > clock_timestamp()`,
74
+ [hash],
75
+ );
76
+ const row = rows[0];
77
+ return (
78
+ row && {
79
+ deployment: row.deployment,
80
+ company: row.company,
81
+ environment: row.environment,
82
+ serviceAccount: row.service_account,
83
+ generation: Number(row.generation),
84
+ }
85
+ );
86
+ },
87
+ };
88
+ }
89
+
90
+ export interface CloudAccessToken {
91
+ accessToken: string;
92
+ expiresAt: number;
93
+ }
94
+ export interface IdentityAudit {
95
+ result: "issued" | "denied" | "unavailable";
96
+ enrollment?: IdentityEnrollment;
97
+ expiresAt?: number;
98
+ }
99
+ export interface IdentityBrokerOptions {
100
+ enrollments: IdentityEnrollments;
101
+ /** Trusted issuer with attached platform authority; target comes exclusively from enrollment. */
102
+ issue(serviceAccount: string): Promise<CloudAccessToken>;
103
+ /** Must durably record the decision without token values. A failure prevents delivery. */
104
+ audit(event: IdentityAudit): Promise<void>;
105
+ }
106
+ const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
107
+ !!e &&
108
+ [e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
109
+ ACCOUNT.test(e.serviceAccount) &&
110
+ Number.isSafeInteger(e.generation) &&
111
+ e.generation > 0;
112
+ const validToken = (token: CloudAccessToken, now: number) =>
113
+ typeof token?.accessToken === "string" &&
114
+ token.accessToken.length > 0 &&
115
+ token.accessToken.length <= 8192 &&
116
+ !/[\s\r\n]/.test(token.accessToken) &&
117
+ Number.isFinite(token.expiresAt) &&
118
+ token.expiresAt > now &&
119
+ token.expiresAt <= now + LIFETIME + 30_000;
120
+ const reply = (status: number, body: unknown) =>
121
+ Response.json(body, {
122
+ status,
123
+ headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
124
+ });
125
+
126
+ // Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
127
+ // without buffering attacker-controlled input or waiting indefinitely for its end.
128
+ async function hasBody(request: Request): Promise<boolean> {
129
+ if (!request.body) return false;
130
+ const reader = request.body.getReader();
131
+ let timeout: ReturnType<typeof setTimeout> | undefined;
132
+ try {
133
+ const first = await Promise.race([
134
+ reader.read(),
135
+ new Promise<never>((_, reject) => {
136
+ timeout = setTimeout(() => reject(new CloudIdentityError()), 5_000);
137
+ }),
138
+ ]);
139
+ return !first.done;
140
+ } finally {
141
+ clearTimeout(timeout);
142
+ void reader.cancel().catch(() => {});
143
+ }
144
+ }
145
+
146
+ /** POST /token with a deployment bearer credential and no query or body. No enrollment endpoint. */
147
+ export function identityBroker(options: IdentityBrokerOptions): (request: Request) => Promise<Response> {
148
+ return async (request) => {
149
+ let enrollment: IdentityEnrollment | undefined;
150
+ try {
151
+ const url = new URL(request.url);
152
+ if (url.pathname !== "/token" || request.method !== "POST") return reply(404, { error: "not_found" });
153
+ if (url.search || (await hasBody(request))) return reply(400, { error: "invalid_request" });
154
+ const authorization = request.headers.get("authorization") ?? "";
155
+ const credential = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
156
+ const hash = CREDENTIAL.test(credential) ? credentialHash(credential) : undefined;
157
+ enrollment = hash ? await options.enrollments.active(hash) : undefined;
158
+ if (!validEnrollment(enrollment)) {
159
+ await options.audit({ result: "denied" });
160
+ return reply(401, { error: "unauthorized" });
161
+ }
162
+ // Copy authoritative values before any awaits; caller fields never select a target or lifetime.
163
+ enrollment = {
164
+ deployment: enrollment.deployment,
165
+ company: enrollment.company,
166
+ environment: enrollment.environment,
167
+ serviceAccount: enrollment.serviceAccount,
168
+ generation: enrollment.generation,
169
+ };
170
+ const token = await options.issue(enrollment.serviceAccount);
171
+ if (!validToken(token, Date.now())) throw new CloudIdentityError();
172
+ const current = await options.enrollments.active(hash!);
173
+ if (
174
+ !current ||
175
+ (Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
176
+ ) {
177
+ await options.audit({ result: "denied", enrollment });
178
+ return reply(401, { error: "unauthorized" });
179
+ }
180
+ await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
181
+ return reply(200, { accessToken: token.accessToken, expiresAt: token.expiresAt });
182
+ } catch {
183
+ try {
184
+ await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
185
+ } catch {
186
+ /* fail closed */
187
+ }
188
+ return reply(503, { error: "unavailable" });
189
+ }
190
+ };
191
+ }
192
+
193
+ /** Cloud Run attached identity only: no ADC environment file or service-account private key fallback. */
194
+ export function attachedGoogleTokenIssuer(): IdentityBrokerOptions["issue"] {
195
+ const auth = new Compute({ scopes: [SCOPE] });
196
+ return async (serviceAccount) => {
197
+ if (!ACCOUNT.test(serviceAccount)) throw new CloudIdentityError();
198
+ try {
199
+ const response = await auth.request<{ accessToken: string; expireTime: string }>({
200
+ url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`,
201
+ method: "POST",
202
+ data: { scope: [SCOPE], lifetime: "600s" },
203
+ timeout: 10_000,
204
+ });
205
+ const token = { accessToken: response.data.accessToken, expiresAt: Date.parse(response.data.expireTime) };
206
+ if (!validToken(token, Date.now())) throw new CloudIdentityError();
207
+ return token;
208
+ } catch {
209
+ throw new CloudIdentityError();
210
+ }
211
+ };
212
+ }
213
+
214
+ /** A per-runtime Google SDK auth client. Short-lived access tokens are kept only in this instance's memory. */
215
+ export function cloudIdentityAuth(options: {
216
+ endpoint: string;
217
+ /** Read the current deployment credential from protected bootstrap; called again on renewal. */
218
+ credential(): Promise<string>;
219
+ }): OAuth2Client {
220
+ let endpoint: URL;
221
+ try {
222
+ endpoint = new URL(options.endpoint);
223
+ } catch {
224
+ throw new CloudIdentityError();
225
+ }
226
+ if (
227
+ endpoint.protocol !== "https:" ||
228
+ endpoint.username ||
229
+ endpoint.password ||
230
+ endpoint.search ||
231
+ endpoint.hash ||
232
+ endpoint.pathname !== "/token"
233
+ )
234
+ throw new CloudIdentityError();
235
+ const client = new OAuth2Client({ eagerRefreshThresholdMillis: 60_000 });
236
+ let pending: Promise<{ access_token: string; expiry_date: number }> | undefined;
237
+ client.refreshHandler = () => {
238
+ if (pending) return pending;
239
+ pending = (async () => {
240
+ try {
241
+ const credential = await options.credential();
242
+ if (!CREDENTIAL.test(credential)) throw new CloudIdentityError();
243
+ const response = await fetch(endpoint, {
244
+ method: "POST",
245
+ headers: { Authorization: `Bearer ${credential}` },
246
+ cache: "no-store",
247
+ redirect: "error",
248
+ signal: AbortSignal.timeout(10_000),
249
+ });
250
+ if (!response.ok) throw new CloudIdentityError();
251
+ const raw = await response.text();
252
+ if (raw.length > 16_384) throw new CloudIdentityError();
253
+ const token = JSON.parse(raw) as CloudAccessToken;
254
+ const now = Date.now();
255
+ if (!validToken(token, now) || token.expiresAt <= now + 60_000) throw new CloudIdentityError();
256
+ return { access_token: token.accessToken, expiry_date: token.expiresAt };
257
+ } catch {
258
+ throw new CloudIdentityError();
259
+ } finally {
260
+ pending = undefined;
261
+ }
262
+ })();
263
+ return pending;
264
+ };
265
+ return client;
266
+ }
package/src/connection.ts CHANGED
@@ -565,7 +565,10 @@ export class ConnectionRegistry {
565
565
  const method = this.catalog.methods.get(row.vendor)![row.method]!;
566
566
  const bundle = row.credential ? await this.secrets?.read(row.credential) : undefined;
567
567
  if (row.credential && !bundle) throw new SecretStoreError("unavailable");
568
- const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value));
568
+ const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value), {
569
+ connectionId: row.id,
570
+ vendor: row.vendor,
571
+ });
569
572
  if (identity.account !== row.account) throw new AuthenticationError("wrong_account");
570
573
  if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
571
574
  throw new AuthenticationError("scope_changed");
@@ -670,7 +673,10 @@ export class ConnectionRegistry {
670
673
  material.credentials = decodeCredentials(method, bundle.value);
671
674
  material.credentialVersion = bundle.version;
672
675
  }
673
- const identity = await verifyAuthentication(method, material.credentials);
676
+ const identity = await verifyAuthentication(method, material.credentials, {
677
+ connectionId: selected.id,
678
+ vendor: selected.vendor,
679
+ });
674
680
  if (identity.account !== selected.account) throw new AuthenticationError("wrong_account");
675
681
  if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(selected.scopes)].toSorted()))
676
682
  throw new AuthenticationError("scope_changed");
package/src/index.ts CHANGED
@@ -292,6 +292,7 @@ export {
292
292
  type AuthenticationProblem,
293
293
  type Authentication,
294
294
  type AuthenticationIdentity,
295
+ type AuthenticationContext,
295
296
  type CallAuthentication,
296
297
  } from "./authentication.ts";
297
298
  export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
@@ -0,0 +1,271 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
2
+ import type { protos } from "@google-cloud/kms";
3
+ import crc32c from "crc-32/crc32c.js";
4
+ import type { Db } from "./db.ts";
5
+ import { SecretStoreError, type SecretAddress, type SecretStore, type SecretValue } from "./secret-store.ts";
6
+
7
+ /** Install with a privileged connection; runtime needs only SELECT/INSERT/UPDATE/DELETE on its company database. */
8
+ export const KMS_SECRETS_SQL = `
9
+ CREATE SCHEMA IF NOT EXISTS sanoma_kms;
10
+ REVOKE ALL ON SCHEMA sanoma_kms FROM PUBLIC;
11
+ CREATE TABLE IF NOT EXISTS sanoma_kms.secrets (
12
+ company text NOT NULL, namespace text NOT NULL, path text NOT NULL,
13
+ version uuid NOT NULL, envelope jsonb NOT NULL,
14
+ updated_by text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(),
15
+ PRIMARY KEY (company, namespace, path)
16
+ );
17
+ REVOKE ALL ON sanoma_kms.secrets FROM PUBLIC;
18
+ DO $revoke$
19
+ DECLARE api_role text;
20
+ BEGIN
21
+ FOREACH api_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
22
+ IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = api_role) THEN
23
+ EXECUTE format('REVOKE ALL ON SCHEMA sanoma_kms FROM %I', api_role);
24
+ EXECUTE format('REVOKE ALL ON sanoma_kms.secrets FROM %I', api_role);
25
+ END IF;
26
+ END LOOP;
27
+ END;
28
+ $revoke$;
29
+ `;
30
+
31
+ /** Promise interface implemented by Google's KeyManagementServiceClient. */
32
+ export interface GcpKmsClient {
33
+ encrypt(
34
+ request: protos.google.cloud.kms.v1.IEncryptRequest,
35
+ ): Promise<[protos.google.cloud.kms.v1.IEncryptResponse, ...unknown[]]>;
36
+ decrypt(
37
+ request: protos.google.cloud.kms.v1.IDecryptRequest,
38
+ ): Promise<[protos.google.cloud.kms.v1.IDecryptResponse, ...unknown[]]>;
39
+ }
40
+
41
+ export interface KmsPostgresOptions {
42
+ /** Fixed by trusted deployment bootstrap. IAM must independently restrict the runtime to this company’s CryptoKeys. */
43
+ company: string;
44
+ /** Full GCP CryptoKey resource name for new writes, using its primary version. */
45
+ keyName: string;
46
+ /** Retained CryptoKey names allowed for reads during key replacement/backup recovery. */
47
+ decryptKeyNames?: readonly string[];
48
+ /** Explicit client under a company-scoped IAM identity, never fleet credentials. Caller owns its lifecycle. */
49
+ kms: GcpKmsClient;
50
+ /** Company-scoped database access. Caller owns connection lifecycle; never shared admin authority. */
51
+ database: () => Promise<Db>;
52
+ }
53
+
54
+ interface Envelope {
55
+ format: "sanoma-gcp-kms-v1";
56
+ key: string;
57
+ keyVersion: string;
58
+ wrapped: string;
59
+ nonce: string;
60
+ ciphertext: string;
61
+ tag: string;
62
+ }
63
+ interface Row {
64
+ version: string;
65
+ envelope: Envelope;
66
+ updated_by: string;
67
+ updated_at: Date;
68
+ }
69
+ const FORMAT = "sanoma-gcp-kms-v1";
70
+ const MAX_BYTES = 65_536;
71
+ const keyName =
72
+ /^projects\/[a-zA-Z0-9-]+\/locations\/[a-z0-9-]+\/keyRings\/[a-zA-Z0-9_-]+\/cryptoKeys\/[a-zA-Z0-9_-]+$/;
73
+ const nonempty = (v: string) =>
74
+ typeof v === "string" && v.length > 0 && Buffer.byteLength(v, "utf8") <= 1024 && !v.includes("\0");
75
+ const failed = (error: unknown): never => {
76
+ if (error instanceof SecretStoreError) throw error;
77
+ const code = (error as { code?: string | number } | null)?.code;
78
+ throw new SecretStoreError(
79
+ code === 7 || code === 16 || code === "42501"
80
+ ? "denied"
81
+ : code === 3
82
+ ? "invalid_value"
83
+ : code === 5 || code === 9 || code === "42P01"
84
+ ? "invalid_configuration"
85
+ : "unavailable",
86
+ );
87
+ };
88
+ const safe = async <T>(run: () => Promise<T>): Promise<T> => {
89
+ try {
90
+ return await run();
91
+ } catch (e) {
92
+ return failed(e);
93
+ }
94
+ };
95
+ const bytes = (v: string, length?: number, max = MAX_BYTES): Buffer => {
96
+ if (
97
+ typeof v !== "string" ||
98
+ v.length > Math.ceil((length ?? max) / 3) * 4 ||
99
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(v)
100
+ )
101
+ throw new SecretStoreError("invalid_value");
102
+ const b = Buffer.from(v, "base64");
103
+ if (length !== undefined && b.length !== length) throw new SecretStoreError("invalid_value");
104
+ return b;
105
+ };
106
+
107
+ const versionOf = (name: unknown, key: string): name is string =>
108
+ typeof name === "string" &&
109
+ name.startsWith(`${key}/cryptoKeyVersions/`) &&
110
+ /^[1-9][0-9]{0,19}$/.test(name.slice(`${key}/cryptoKeyVersions/`.length));
111
+ const checksum = (value: Uint8Array) => crc32c.buf(value) >>> 0;
112
+
113
+ /** Versioned envelope encryption; no plaintext/data-key cache, no SANOMA_SECRETS_KEY and no Secrets Manager. */
114
+ export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
115
+ const { company, keyName: writeKey, kms, database } = options;
116
+ const keys = new Set([writeKey, ...(options.decryptKeyNames ?? [])]);
117
+ if (!nonempty(company) || [...keys].some((k) => !nonempty(k) || !keyName.test(k)))
118
+ throw new SecretStoreError("invalid_configuration");
119
+ const context = (address: SecretAddress, version: string) => {
120
+ if (!nonempty(address.namespace) || !nonempty(address.path)) throw new SecretStoreError("invalid_configuration");
121
+ return { company, namespace: address.namespace, path: address.path, version, format: FORMAT };
122
+ };
123
+ const seal = async (address: SecretAddress, value: string, version: string): Promise<Envelope> => {
124
+ const bound = context(address, version);
125
+ const aad = Buffer.from(JSON.stringify(bound));
126
+ const dataKey = randomBytes(32);
127
+ try {
128
+ const [encrypted] = await kms.encrypt({
129
+ name: writeKey,
130
+ plaintext: dataKey,
131
+ plaintextCrc32c: { value: checksum(dataKey) },
132
+ additionalAuthenticatedData: aad,
133
+ additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
134
+ });
135
+ const wrapped =
136
+ typeof encrypted.ciphertext === "string" ? bytes(encrypted.ciphertext, undefined, 6144) : encrypted.ciphertext;
137
+ if (
138
+ !versionOf(encrypted.name, writeKey) ||
139
+ !wrapped?.length ||
140
+ wrapped.length > 6144 ||
141
+ !encrypted.verifiedPlaintextCrc32c ||
142
+ !encrypted.verifiedAdditionalAuthenticatedDataCrc32c ||
143
+ Number(encrypted.ciphertextCrc32c?.value ?? NaN) !== checksum(wrapped)
144
+ )
145
+ throw new SecretStoreError("invalid_value");
146
+ const nonce = randomBytes(12);
147
+ const cipher = createCipheriv("aes-256-gcm", dataKey, nonce).setAAD(
148
+ Buffer.from(JSON.stringify({ ...bound, keyVersion: encrypted.name })),
149
+ );
150
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
151
+ return {
152
+ format: FORMAT,
153
+ key: writeKey,
154
+ keyVersion: encrypted.name,
155
+ wrapped: Buffer.from(wrapped).toString("base64"),
156
+ nonce: nonce.toString("base64"),
157
+ ciphertext: ciphertext.toString("base64"),
158
+ tag: cipher.getAuthTag().toString("base64"),
159
+ };
160
+ } finally {
161
+ dataKey.fill(0);
162
+ }
163
+ };
164
+ const open = async (address: SecretAddress, row: Row): Promise<SecretValue> => {
165
+ const e = row.envelope;
166
+ if (!e || e.format !== FORMAT || !keys.has(e.key) || !versionOf(e.keyVersion, e.key))
167
+ throw new SecretStoreError("invalid_value");
168
+ const nonce = bytes(e.nonce, 12),
169
+ tag = bytes(e.tag, 16),
170
+ ciphertext = bytes(e.ciphertext),
171
+ wrapped = bytes(e.wrapped, undefined, 6144);
172
+ if (ciphertext.length > MAX_BYTES || !wrapped.length || wrapped.length > 6144)
173
+ throw new SecretStoreError("invalid_value");
174
+ const bound = context(address, row.version);
175
+ const aad = Buffer.from(JSON.stringify(bound));
176
+ const [decrypted] = await kms.decrypt({
177
+ name: e.key,
178
+ ciphertext: wrapped,
179
+ ciphertextCrc32c: { value: checksum(wrapped) },
180
+ additionalAuthenticatedData: aad,
181
+ additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
182
+ });
183
+ const dataKey = typeof decrypted.plaintext === "string" ? bytes(decrypted.plaintext, 32) : decrypted.plaintext;
184
+ try {
185
+ if (dataKey?.length !== 32 || Number(decrypted.plaintextCrc32c?.value ?? NaN) !== checksum(dataKey))
186
+ throw new SecretStoreError("invalid_value");
187
+ const decipher = createDecipheriv("aes-256-gcm", dataKey, nonce)
188
+ .setAAD(Buffer.from(JSON.stringify({ ...bound, keyVersion: e.keyVersion })))
189
+ .setAuthTag(tag);
190
+ let plaintext: Buffer;
191
+ try {
192
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
193
+ } catch {
194
+ throw new SecretStoreError("invalid_value");
195
+ }
196
+ try {
197
+ return {
198
+ value: plaintext.toString("utf8"),
199
+ version: row.version,
200
+ updatedBy: row.updated_by,
201
+ updatedAt: row.updated_at.toISOString(),
202
+ };
203
+ } finally {
204
+ plaintext.fill(0);
205
+ }
206
+ } finally {
207
+ dataKey?.fill(0);
208
+ }
209
+ };
210
+ return {
211
+ id: "kms-postgres",
212
+ capabilities: Object.freeze({
213
+ writable: true,
214
+ maxBytes: MAX_BYTES,
215
+ conditionalWrites: "atomic",
216
+ deletion: "destroy",
217
+ changeDetection: "read",
218
+ }),
219
+ validate: (namespace) =>
220
+ safe(async () => {
221
+ const address = { namespace, path: "__validation__" },
222
+ version = randomUUID();
223
+ const envelope = await seal(address, "validation", version);
224
+ await open(address, { envelope, version, updated_by: "validation", updated_at: new Date() });
225
+ await (await database()).query("SELECT version FROM sanoma_kms.secrets WHERE company=$1 LIMIT 0", [company]);
226
+ // Own-key usability only. Cross-company authorization requires live IAM proof with another existing key.
227
+ }),
228
+ read: (address) =>
229
+ safe(async () => {
230
+ context(address, "read");
231
+ const { rows } = await (
232
+ await database()
233
+ ).query<Row>(
234
+ "SELECT version,envelope,updated_by,updated_at FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3",
235
+ [company, address.namespace, address.path],
236
+ );
237
+ return rows[0] && open(address, rows[0]);
238
+ }),
239
+ write: (address, value, { expectedVersion, by }) =>
240
+ safe(async () => {
241
+ if (Buffer.byteLength(value, "utf8") > MAX_BYTES) throw new SecretStoreError("too_large");
242
+ if (!nonempty(by)) throw new SecretStoreError("invalid_value");
243
+ const version = randomUUID(),
244
+ envelope = await seal(address, value, version);
245
+ const args = [company, address.namespace, address.path, version, JSON.stringify(envelope), by];
246
+ const sql =
247
+ expectedVersion === null
248
+ ? "INSERT INTO sanoma_kms.secrets(company,namespace,path,version,envelope,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(company,namespace,path) DO NOTHING RETURNING version,envelope,updated_by,updated_at"
249
+ : "UPDATE sanoma_kms.secrets SET version=$4,envelope=$5,updated_by=$6,updated_at=now() WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$7 RETURNING version,envelope,updated_by,updated_at";
250
+ const { rows } = await (
251
+ await database()
252
+ ).query<Row>(sql, expectedVersion === null ? args : [...args, expectedVersion]);
253
+ if (!rows[0]) throw new SecretStoreError("conflict");
254
+ // Return the caller's value, never make a second decrypt after a successful commit.
255
+ return { value, version, updatedBy: by, updatedAt: rows[0].updated_at.toISOString() };
256
+ }),
257
+ delete: (address, { expectedVersion }) =>
258
+ safe(async () => {
259
+ context(address, expectedVersion);
260
+ const { rowCount } = await (
261
+ await database()
262
+ ).query("DELETE FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$4", [
263
+ company,
264
+ address.namespace,
265
+ address.path,
266
+ expectedVersion,
267
+ ]);
268
+ if (!rowCount) throw new SecretStoreError("conflict");
269
+ }),
270
+ };
271
+ }