@elevasis/core 0.12.0 → 0.13.0
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/dist/test-utils/index.d.ts +17 -12
- package/dist/test-utils/index.js +19 -0
- package/package.json +1 -1
- package/src/auth/multi-tenancy/credentials/__tests__/encryption.test.ts +217 -216
- package/src/auth/multi-tenancy/credentials/server/encryption.ts +5 -19
- package/src/auth/multi-tenancy/credentials/server/kek-loader.ts +3 -13
- package/src/auth/multi-tenancy/permissions.ts +12 -5
- package/src/business/acquisition/activity-events.ts +142 -0
- package/src/business/acquisition/api-schemas.ts +694 -689
- package/src/business/acquisition/derive-actions.ts +90 -0
- package/src/business/acquisition/index.ts +111 -109
- package/src/execution/engine/index.ts +434 -434
- package/src/execution/engine/tools/integration/server/adapters/apify/__tests__/apify-run-actor.integration.test.ts +1 -2
- package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +0 -1
- package/src/execution/engine/tools/lead-service-types.ts +882 -879
- package/src/execution/engine/tools/registry.ts +699 -700
- package/src/execution/engine/tools/tool-maps.ts +777 -780
- package/src/organization-model/organization-graph.mdx +2 -2
- package/src/platform/constants/versions.ts +1 -1
- package/src/scaffold-registry/index.ts +10 -9
- package/src/scaffold-registry/schema.ts +68 -62
- package/src/supabase/database.types.ts +9 -7
- package/src/test-utils/rls/RLSTestContext.ts +585 -553
|
@@ -413,7 +413,6 @@ type Database = {
|
|
|
413
413
|
acq_deals: {
|
|
414
414
|
Row: {
|
|
415
415
|
activity_log: Json;
|
|
416
|
-
cached_stage: string | null;
|
|
417
416
|
closed_lost_at: string | null;
|
|
418
417
|
closed_lost_reason: string | null;
|
|
419
418
|
contact_email: string;
|
|
@@ -428,6 +427,7 @@ type Database = {
|
|
|
428
427
|
organization_id: string;
|
|
429
428
|
payment_link_sent_at: string | null;
|
|
430
429
|
payment_received_at: string | null;
|
|
430
|
+
pipeline_key: string;
|
|
431
431
|
proposal_data: Json | null;
|
|
432
432
|
proposal_generated_at: string | null;
|
|
433
433
|
proposal_pdf_url: string | null;
|
|
@@ -435,10 +435,11 @@ type Database = {
|
|
|
435
435
|
proposal_reviewed_by: string | null;
|
|
436
436
|
proposal_sent_at: string | null;
|
|
437
437
|
proposal_signed_at: string | null;
|
|
438
|
-
proposal_status: string | null;
|
|
439
438
|
signature_envelope_id: string | null;
|
|
440
439
|
source_list_id: string | null;
|
|
441
440
|
source_type: string | null;
|
|
441
|
+
stage_key: string | null;
|
|
442
|
+
state_key: string | null;
|
|
442
443
|
stripe_payment_id: string | null;
|
|
443
444
|
stripe_payment_link: string | null;
|
|
444
445
|
stripe_payment_link_id: string | null;
|
|
@@ -447,7 +448,6 @@ type Database = {
|
|
|
447
448
|
};
|
|
448
449
|
Insert: {
|
|
449
450
|
activity_log?: Json;
|
|
450
|
-
cached_stage?: string | null;
|
|
451
451
|
closed_lost_at?: string | null;
|
|
452
452
|
closed_lost_reason?: string | null;
|
|
453
453
|
contact_email: string;
|
|
@@ -462,6 +462,7 @@ type Database = {
|
|
|
462
462
|
organization_id: string;
|
|
463
463
|
payment_link_sent_at?: string | null;
|
|
464
464
|
payment_received_at?: string | null;
|
|
465
|
+
pipeline_key?: string;
|
|
465
466
|
proposal_data?: Json | null;
|
|
466
467
|
proposal_generated_at?: string | null;
|
|
467
468
|
proposal_pdf_url?: string | null;
|
|
@@ -469,10 +470,11 @@ type Database = {
|
|
|
469
470
|
proposal_reviewed_by?: string | null;
|
|
470
471
|
proposal_sent_at?: string | null;
|
|
471
472
|
proposal_signed_at?: string | null;
|
|
472
|
-
proposal_status?: string | null;
|
|
473
473
|
signature_envelope_id?: string | null;
|
|
474
474
|
source_list_id?: string | null;
|
|
475
475
|
source_type?: string | null;
|
|
476
|
+
stage_key?: string | null;
|
|
477
|
+
state_key?: string | null;
|
|
476
478
|
stripe_payment_id?: string | null;
|
|
477
479
|
stripe_payment_link?: string | null;
|
|
478
480
|
stripe_payment_link_id?: string | null;
|
|
@@ -481,7 +483,6 @@ type Database = {
|
|
|
481
483
|
};
|
|
482
484
|
Update: {
|
|
483
485
|
activity_log?: Json;
|
|
484
|
-
cached_stage?: string | null;
|
|
485
486
|
closed_lost_at?: string | null;
|
|
486
487
|
closed_lost_reason?: string | null;
|
|
487
488
|
contact_email?: string;
|
|
@@ -496,6 +497,7 @@ type Database = {
|
|
|
496
497
|
organization_id?: string;
|
|
497
498
|
payment_link_sent_at?: string | null;
|
|
498
499
|
payment_received_at?: string | null;
|
|
500
|
+
pipeline_key?: string;
|
|
499
501
|
proposal_data?: Json | null;
|
|
500
502
|
proposal_generated_at?: string | null;
|
|
501
503
|
proposal_pdf_url?: string | null;
|
|
@@ -503,10 +505,11 @@ type Database = {
|
|
|
503
505
|
proposal_reviewed_by?: string | null;
|
|
504
506
|
proposal_sent_at?: string | null;
|
|
505
507
|
proposal_signed_at?: string | null;
|
|
506
|
-
proposal_status?: string | null;
|
|
507
508
|
signature_envelope_id?: string | null;
|
|
508
509
|
source_list_id?: string | null;
|
|
509
510
|
source_type?: string | null;
|
|
511
|
+
stage_key?: string | null;
|
|
512
|
+
state_key?: string | null;
|
|
510
513
|
stripe_payment_id?: string | null;
|
|
511
514
|
stripe_payment_link?: string | null;
|
|
512
515
|
stripe_payment_link_id?: string | null;
|
|
@@ -2755,12 +2758,6 @@ type Database = {
|
|
|
2755
2758
|
};
|
|
2756
2759
|
Returns: boolean;
|
|
2757
2760
|
};
|
|
2758
|
-
is_org_admin: {
|
|
2759
|
-
Args: {
|
|
2760
|
-
org_id: string;
|
|
2761
|
-
};
|
|
2762
|
-
Returns: boolean;
|
|
2763
|
-
};
|
|
2764
2761
|
is_org_member: {
|
|
2765
2762
|
Args: {
|
|
2766
2763
|
org_id: string;
|
|
@@ -2915,6 +2912,14 @@ declare class RLSTestContext {
|
|
|
2915
2912
|
* Create an organization membership
|
|
2916
2913
|
*/
|
|
2917
2914
|
createMembership(userId: string, organizationId: string, role: Role): Promise<Membership>;
|
|
2915
|
+
/**
|
|
2916
|
+
* Assign a system role to a membership via org_rol_assignments.
|
|
2917
|
+
* After the 2026-04-25 auth refactor, RLS policies read `effective_permissions[]`
|
|
2918
|
+
* (materialized by trigger from org_rol_assignments → org_rol_grants).
|
|
2919
|
+
* Without this assignment, role_slug is informational only and the membership
|
|
2920
|
+
* has zero permissions, causing all has_org_permission() checks to deny.
|
|
2921
|
+
*/
|
|
2922
|
+
private assignSystemRole;
|
|
2918
2923
|
/**
|
|
2919
2924
|
* Create a pre-provisioned organization membership (without WorkOS membership ID)
|
|
2920
2925
|
* Used for testing invitation flows where memberships are created before user accepts
|
package/dist/test-utils/index.js
CHANGED
|
@@ -1991,6 +1991,7 @@ var RLSTestContext = class {
|
|
|
1991
1991
|
throw new Error(`Failed to create membership: ${error.message}`);
|
|
1992
1992
|
}
|
|
1993
1993
|
this.createdIds.memberships.push(data.id);
|
|
1994
|
+
await this.assignSystemRole(data.id, role);
|
|
1994
1995
|
return {
|
|
1995
1996
|
id: data.id,
|
|
1996
1997
|
user_id: data.user_id,
|
|
@@ -1998,6 +1999,23 @@ var RLSTestContext = class {
|
|
|
1998
1999
|
role_slug: data.role_slug
|
|
1999
2000
|
};
|
|
2000
2001
|
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Assign a system role to a membership via org_rol_assignments.
|
|
2004
|
+
* After the 2026-04-25 auth refactor, RLS policies read `effective_permissions[]`
|
|
2005
|
+
* (materialized by trigger from org_rol_assignments → org_rol_grants).
|
|
2006
|
+
* Without this assignment, role_slug is informational only and the membership
|
|
2007
|
+
* has zero permissions, causing all has_org_permission() checks to deny.
|
|
2008
|
+
*/
|
|
2009
|
+
async assignSystemRole(membershipId, slug) {
|
|
2010
|
+
const { data: roleDef, error: roleErr } = await this.adminClient.from("org_rol_definitions").select("id").is("organization_id", null).eq("slug", slug).single();
|
|
2011
|
+
if (roleErr || !roleDef) {
|
|
2012
|
+
throw new Error(`Failed to look up system role '${slug}': ${roleErr?.message ?? "not found"}`);
|
|
2013
|
+
}
|
|
2014
|
+
const { error: assignErr } = await this.adminClient.from("org_rol_assignments").insert({ membership_id: membershipId, role_id: roleDef.id });
|
|
2015
|
+
if (assignErr) {
|
|
2016
|
+
throw new Error(`Failed to assign system role '${slug}' to membership: ${assignErr.message}`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2001
2019
|
/**
|
|
2002
2020
|
* Create a pre-provisioned organization membership (without WorkOS membership ID)
|
|
2003
2021
|
* Used for testing invitation flows where memberships are created before user accepts
|
|
@@ -2016,6 +2034,7 @@ var RLSTestContext = class {
|
|
|
2016
2034
|
throw new Error(`Failed to create pre-provisioned membership: ${error.message}`);
|
|
2017
2035
|
}
|
|
2018
2036
|
this.createdIds.memberships.push(data.id);
|
|
2037
|
+
await this.assignSystemRole(data.id, role);
|
|
2019
2038
|
return {
|
|
2020
2039
|
id: data.id,
|
|
2021
2040
|
user_id: data.user_id,
|
package/package.json
CHANGED
|
@@ -1,216 +1,217 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
expect(parsed).toHaveProperty('
|
|
20
|
-
expect(parsed).toHaveProperty('
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
expect(decryptCredential(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
dataBuffer
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
expect(parsed.authTag
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
})
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
3
|
+
import { encryptCredential, decryptCredential, setKek, CURRENT_KEY_ID } from '../server/encryption'
|
|
4
|
+
|
|
5
|
+
beforeAll(() => {
|
|
6
|
+
setKek(CURRENT_KEY_ID, crypto.randomBytes(32))
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('Credential Encryption', () => {
|
|
10
|
+
describe('encryptCredential', () => {
|
|
11
|
+
it('encrypts plaintext to base64-encoded JSON string', () => {
|
|
12
|
+
const plaintext = 'my-secret-api-key'
|
|
13
|
+
const encrypted = encryptCredential(plaintext)
|
|
14
|
+
|
|
15
|
+
// Should be a valid JSON string
|
|
16
|
+
expect(() => JSON.parse(encrypted)).not.toThrow()
|
|
17
|
+
|
|
18
|
+
const parsed = JSON.parse(encrypted)
|
|
19
|
+
expect(parsed).toHaveProperty('iv')
|
|
20
|
+
expect(parsed).toHaveProperty('authTag')
|
|
21
|
+
expect(parsed).toHaveProperty('data')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('produces different ciphertext for same input (random IV)', () => {
|
|
25
|
+
const plaintext = 'same-secret'
|
|
26
|
+
|
|
27
|
+
const encrypted1 = encryptCredential(plaintext)
|
|
28
|
+
const encrypted2 = encryptCredential(plaintext)
|
|
29
|
+
|
|
30
|
+
// Different IVs mean different ciphertext
|
|
31
|
+
expect(encrypted1).not.toBe(encrypted2)
|
|
32
|
+
|
|
33
|
+
// But both decrypt to same plaintext
|
|
34
|
+
expect(decryptCredential(encrypted1)).toBe(plaintext)
|
|
35
|
+
expect(decryptCredential(encrypted2)).toBe(plaintext)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles empty string', () => {
|
|
39
|
+
const plaintext = ''
|
|
40
|
+
const encrypted = encryptCredential(plaintext)
|
|
41
|
+
const decrypted = decryptCredential(encrypted)
|
|
42
|
+
|
|
43
|
+
expect(decrypted).toBe('')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('handles long strings', () => {
|
|
47
|
+
const plaintext = 'x'.repeat(10000)
|
|
48
|
+
const encrypted = encryptCredential(plaintext)
|
|
49
|
+
const decrypted = decryptCredential(encrypted)
|
|
50
|
+
|
|
51
|
+
expect(decrypted).toBe(plaintext)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('handles special characters and unicode', () => {
|
|
55
|
+
const plaintext = 'Hello 世界! 🔐 Special chars: \n\t\r"\'\\`'
|
|
56
|
+
const encrypted = encryptCredential(plaintext)
|
|
57
|
+
const decrypted = decryptCredential(encrypted)
|
|
58
|
+
|
|
59
|
+
expect(decrypted).toBe(plaintext)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('validates key is configured at module load', () => {
|
|
63
|
+
// We can't test missing key dynamically since MASTER_KEY is set at module load
|
|
64
|
+
// Instead, verify the key is set and encryption works
|
|
65
|
+
const encrypted = encryptCredential('test')
|
|
66
|
+
expect(encrypted).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('decryptCredential', () => {
|
|
71
|
+
it('decrypts ciphertext back to original plaintext', () => {
|
|
72
|
+
const plaintext = 'my-secret-api-key'
|
|
73
|
+
const encrypted = encryptCredential(plaintext)
|
|
74
|
+
const decrypted = decryptCredential(encrypted)
|
|
75
|
+
|
|
76
|
+
expect(decrypted).toBe(plaintext)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('successfully decrypts with configured key', () => {
|
|
80
|
+
const encrypted = encryptCredential('test')
|
|
81
|
+
const decrypted = decryptCredential(encrypted)
|
|
82
|
+
|
|
83
|
+
expect(decrypted).toBe('test')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws error on malformed JSON', () => {
|
|
87
|
+
expect(() => decryptCredential('not-json')).toThrow()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('throws error on missing fields', () => {
|
|
91
|
+
const invalidData = JSON.stringify({ iv: 'abc', authTag: 'def' }) // Missing 'data'
|
|
92
|
+
|
|
93
|
+
expect(() => decryptCredential(invalidData)).toThrow()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('throws error on tampered ciphertext', () => {
|
|
97
|
+
const encrypted = encryptCredential('secret')
|
|
98
|
+
const parsed = JSON.parse(encrypted)
|
|
99
|
+
|
|
100
|
+
// Tamper with the data
|
|
101
|
+
parsed.data = 'tampered-data'
|
|
102
|
+
const tampered = JSON.stringify(parsed)
|
|
103
|
+
|
|
104
|
+
expect(() => decryptCredential(tampered)).toThrow()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('throws error on tampered auth tag', () => {
|
|
108
|
+
const encrypted = encryptCredential('secret')
|
|
109
|
+
const parsed = JSON.parse(encrypted)
|
|
110
|
+
|
|
111
|
+
// Tamper with the auth tag
|
|
112
|
+
parsed.authTag = 'AAAAAAAAAAAAAAAAAAAAAA=='
|
|
113
|
+
const tampered = JSON.stringify(parsed)
|
|
114
|
+
|
|
115
|
+
expect(() => decryptCredential(tampered)).toThrow()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('detects tampering via auth tag (GCM mode)', () => {
|
|
119
|
+
const encrypted = encryptCredential('secret')
|
|
120
|
+
const parsed = JSON.parse(encrypted)
|
|
121
|
+
|
|
122
|
+
// Tamper with just one byte of data
|
|
123
|
+
const dataBuffer = Buffer.from(parsed.data, 'base64')
|
|
124
|
+
dataBuffer[0] ^= 0x01 // Flip one bit
|
|
125
|
+
parsed.data = dataBuffer.toString('base64')
|
|
126
|
+
|
|
127
|
+
const tampered = JSON.stringify(parsed)
|
|
128
|
+
|
|
129
|
+
// GCM should detect the tampering and throw
|
|
130
|
+
expect(() => decryptCredential(tampered)).toThrow()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('throws error on invalid base64 encoding', () => {
|
|
134
|
+
const invalidData = JSON.stringify({
|
|
135
|
+
iv: 'not-valid-base64!!!',
|
|
136
|
+
authTag: 'AAAA',
|
|
137
|
+
data: 'BBBB'
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(() => decryptCredential(invalidData)).toThrow()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('Round-trip encryption/decryption', () => {
|
|
145
|
+
it('preserves API key format', () => {
|
|
146
|
+
const apiKey = 'sk_test_1234567890abcdefghijklmnopqrstuvwxyz'
|
|
147
|
+
const encrypted = encryptCredential(apiKey)
|
|
148
|
+
const decrypted = decryptCredential(encrypted)
|
|
149
|
+
|
|
150
|
+
expect(decrypted).toBe(apiKey)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('preserves database connection strings', () => {
|
|
154
|
+
const connString = 'postgresql://user:password@localhost:5432/database?sslmode=require'
|
|
155
|
+
const encrypted = encryptCredential(connString)
|
|
156
|
+
const decrypted = decryptCredential(encrypted)
|
|
157
|
+
|
|
158
|
+
expect(decrypted).toBe(connString)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('preserves JSON credentials', () => {
|
|
162
|
+
const jsonCreds = JSON.stringify({
|
|
163
|
+
type: 'service_account',
|
|
164
|
+
project_id: 'my-project',
|
|
165
|
+
private_key: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n'
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const encrypted = encryptCredential(jsonCreds)
|
|
169
|
+
const decrypted = decryptCredential(encrypted)
|
|
170
|
+
|
|
171
|
+
expect(decrypted).toBe(jsonCreds)
|
|
172
|
+
expect(JSON.parse(decrypted)).toEqual(JSON.parse(jsonCreds))
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('Security properties', () => {
|
|
177
|
+
it('uses AES-256-GCM (authenticated encryption)', () => {
|
|
178
|
+
const encrypted = encryptCredential('test')
|
|
179
|
+
const parsed = JSON.parse(encrypted)
|
|
180
|
+
|
|
181
|
+
// Auth tag should be present (GCM mode)
|
|
182
|
+
expect(parsed.authTag).toBeDefined()
|
|
183
|
+
expect(parsed.authTag.length).toBeGreaterThan(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('uses random IV for each encryption', () => {
|
|
187
|
+
const ivs = new Set()
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < 100; i++) {
|
|
190
|
+
const encrypted = encryptCredential('test')
|
|
191
|
+
const parsed = JSON.parse(encrypted)
|
|
192
|
+
ivs.add(parsed.iv)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// All IVs should be unique
|
|
196
|
+
expect(ivs.size).toBe(100)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('IV is 16 bytes (128 bits)', () => {
|
|
200
|
+
const encrypted = encryptCredential('test')
|
|
201
|
+
const parsed = JSON.parse(encrypted)
|
|
202
|
+
|
|
203
|
+
// Base64-encoded 16 bytes = 24 characters
|
|
204
|
+
const ivBuffer = Buffer.from(parsed.iv, 'base64')
|
|
205
|
+
expect(ivBuffer.length).toBe(16)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('auth tag is 16 bytes (128 bits)', () => {
|
|
209
|
+
const encrypted = encryptCredential('test')
|
|
210
|
+
const parsed = JSON.parse(encrypted)
|
|
211
|
+
|
|
212
|
+
// Base64-encoded 16 bytes = 24 characters
|
|
213
|
+
const authTagBuffer = Buffer.from(parsed.authTag, 'base64')
|
|
214
|
+
expect(authTagBuffer.length).toBe(16)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -5,10 +5,10 @@ const ALGORITHM = 'aes-256-gcm'
|
|
|
5
5
|
// keyId stamped on all newly encrypted ciphertexts.
|
|
6
6
|
export const CURRENT_KEY_ID = 'platform-v1'
|
|
7
7
|
|
|
8
|
-
// Implicit keyId for pre-Vault ciphertext rows that
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Implicit keyId for pre-Vault ciphertext rows that lack a keyId field. All
|
|
9
|
+
// known rows were restamped with CURRENT_KEY_ID during the re-encryption
|
|
10
|
+
// migration; this constant remains only so any unexpected legacy blob produces
|
|
11
|
+
// a clear "KEK not loaded" error rather than a silent default-key decrypt.
|
|
12
12
|
export const LEGACY_KEY_ID = 'platform-v0-legacy'
|
|
13
13
|
|
|
14
14
|
interface EncryptedData {
|
|
@@ -32,21 +32,7 @@ export function clearKeks(): void {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function resolveKek(keyId: string): Buffer | undefined {
|
|
35
|
-
|
|
36
|
-
if (cached) return cached
|
|
37
|
-
|
|
38
|
-
// Non-production fallback: bootstrap from SECRETS_ENCRYPTION_KEY so tests and
|
|
39
|
-
// local dev keep working without an explicit setKek() boot step. Production
|
|
40
|
-
// must register KEKs via the kek-loader; the env var is removed in Wave B5.
|
|
41
|
-
if (process.env.NODE_ENV !== 'production' && process.env.SECRETS_ENCRYPTION_KEY) {
|
|
42
|
-
const envKey = Buffer.from(process.env.SECRETS_ENCRYPTION_KEY, 'hex')
|
|
43
|
-
if (envKey.length === 32) {
|
|
44
|
-
kekMap.set(keyId, envKey)
|
|
45
|
-
return envKey
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return undefined
|
|
35
|
+
return kekMap.get(keyId)
|
|
50
36
|
}
|
|
51
37
|
|
|
52
38
|
export function encryptCredential(plaintext: string): string {
|