@everystack/cli 0.2.13 → 0.2.16
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/package.json +1 -1
- package/src/cli/commands/secrets.ts +37 -98
- package/src/cli/commands/update.ts +31 -33
- package/src/cli/utils/secrets.ts +170 -39
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Secrets management — read/write SST secrets
|
|
2
|
+
* Secrets management — read/write SST Ion secrets.
|
|
3
3
|
*
|
|
4
|
-
* SST
|
|
5
|
-
*
|
|
4
|
+
* SST Ion stores secrets as AES-256-GCM encrypted JSON in S3:
|
|
5
|
+
* Bucket: sst-state-{random} (resolved from /sst/bootstrap SSM param)
|
|
6
|
+
* Key: secret/{appName}/{stage}.json
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
+
* The encryption passphrase lives in SSM at /sst/passphrase/{appName}/{stage}.
|
|
9
|
+
*
|
|
10
|
+
* Full interop with `sst secret set/list` — same S3 objects, same encryption.
|
|
8
11
|
* Secrets set via `everystack secrets set` are readable by SST and vice versa.
|
|
9
12
|
*
|
|
10
|
-
* Requires IAM permissions: ssm:
|
|
11
|
-
* ssm:PutParameter, ssm:DeleteParameter, kms:Decrypt, kms:Encrypt.
|
|
13
|
+
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, s3:PutObject, kms:Decrypt.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { step, success, fail, info, warn } from '../output.js';
|
|
17
|
+
import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
|
|
15
18
|
|
|
16
19
|
interface SecretsContext {
|
|
17
20
|
appName: string;
|
|
@@ -34,16 +37,6 @@ async function resolveContext(flags: Record<string, string>): Promise<SecretsCon
|
|
|
34
37
|
return { appName, stage, region };
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
function ssmPath(ctx: SecretsContext, name?: string): string {
|
|
38
|
-
const base = `/sst/${ctx.appName}/${ctx.stage}/Secret`;
|
|
39
|
-
return name ? `${base}/${name}/value` : `${base}/`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function getSSM(region: string) {
|
|
43
|
-
const { SSMClient } = await import('@aws-sdk/client-ssm');
|
|
44
|
-
return new SSMClient({ region });
|
|
45
|
-
}
|
|
46
|
-
|
|
47
40
|
// ---------------------------------------------------------------------------
|
|
48
41
|
// List
|
|
49
42
|
// ---------------------------------------------------------------------------
|
|
@@ -51,33 +44,8 @@ async function getSSM(region: string) {
|
|
|
51
44
|
async function listSecrets(ctx: SecretsContext): Promise<void> {
|
|
52
45
|
step(`Loading secrets for ${ctx.appName}/${ctx.stage}...`);
|
|
53
46
|
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const names: string[] = [];
|
|
58
|
-
let nextToken: string | undefined;
|
|
59
|
-
|
|
60
|
-
do {
|
|
61
|
-
const response = await client.send(
|
|
62
|
-
new GetParametersByPathCommand({
|
|
63
|
-
Path: ssmPath(ctx),
|
|
64
|
-
Recursive: true,
|
|
65
|
-
WithDecryption: false, // Don't decrypt — we only need names
|
|
66
|
-
NextToken: nextToken,
|
|
67
|
-
}),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
for (const param of response.Parameters || []) {
|
|
71
|
-
if (!param.Name) continue;
|
|
72
|
-
const parts = param.Name.split('/');
|
|
73
|
-
const secretIndex = parts.indexOf('Secret');
|
|
74
|
-
if (secretIndex >= 0 && secretIndex + 1 < parts.length) {
|
|
75
|
-
names.push(parts[secretIndex + 1]);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
nextToken = response.NextToken;
|
|
80
|
-
} while (nextToken);
|
|
47
|
+
const secrets = await loadSstSecrets(ctx);
|
|
48
|
+
const names = Object.keys(secrets).sort();
|
|
81
49
|
|
|
82
50
|
if (names.length === 0) {
|
|
83
51
|
info('No secrets found');
|
|
@@ -86,7 +54,7 @@ async function listSecrets(ctx: SecretsContext): Promise<void> {
|
|
|
86
54
|
}
|
|
87
55
|
|
|
88
56
|
success(`${names.length} secret(s):`);
|
|
89
|
-
for (const name of names
|
|
57
|
+
for (const name of names) {
|
|
90
58
|
console.log(` ${name}`);
|
|
91
59
|
}
|
|
92
60
|
}
|
|
@@ -98,30 +66,15 @@ async function listSecrets(ctx: SecretsContext): Promise<void> {
|
|
|
98
66
|
async function getSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
99
67
|
step(`Reading ${name}...`);
|
|
100
68
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
69
|
+
const secrets = await loadSstSecrets(ctx);
|
|
70
|
+
const value = secrets[name];
|
|
103
71
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
if (response.Parameter?.Value !== undefined) {
|
|
113
|
-
console.log(response.Parameter.Value);
|
|
114
|
-
} else {
|
|
115
|
-
fail(`Secret "${name}" has no value`);
|
|
116
|
-
process.exit(1);
|
|
117
|
-
}
|
|
118
|
-
} catch (err: any) {
|
|
119
|
-
if (err.name === 'ParameterNotFound') {
|
|
120
|
-
fail(`Secret "${name}" not found in stage "${ctx.stage}"`);
|
|
121
|
-
info(`Set it with: everystack secrets set ${name} "value" --stage ${ctx.stage}`);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
throw err;
|
|
72
|
+
if (value !== undefined) {
|
|
73
|
+
console.log(value);
|
|
74
|
+
} else {
|
|
75
|
+
fail(`Secret "${name}" not found in stage "${ctx.stage}"`);
|
|
76
|
+
info(`Set it with: everystack secrets set ${name} "value" --stage ${ctx.stage}`);
|
|
77
|
+
process.exit(1);
|
|
125
78
|
}
|
|
126
79
|
}
|
|
127
80
|
|
|
@@ -132,17 +85,10 @@ async function getSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
|
132
85
|
async function setSecret(ctx: SecretsContext, name: string, value: string): Promise<void> {
|
|
133
86
|
step(`Setting ${name}...`);
|
|
134
87
|
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
await
|
|
139
|
-
new PutParameterCommand({
|
|
140
|
-
Name: ssmPath(ctx, name),
|
|
141
|
-
Value: value,
|
|
142
|
-
Type: 'SecureString',
|
|
143
|
-
Overwrite: true,
|
|
144
|
-
}),
|
|
145
|
-
);
|
|
88
|
+
// Load existing secrets, merge, and write back
|
|
89
|
+
const secrets = await loadSstSecrets(ctx);
|
|
90
|
+
secrets[name] = value;
|
|
91
|
+
await putSstSecrets(ctx, secrets);
|
|
146
92
|
|
|
147
93
|
success(`${name} set for stage "${ctx.stage}"`);
|
|
148
94
|
}
|
|
@@ -154,23 +100,16 @@ async function setSecret(ctx: SecretsContext, name: string, value: string): Prom
|
|
|
154
100
|
async function removeSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
155
101
|
step(`Removing ${name}...`);
|
|
156
102
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await client.send(
|
|
162
|
-
new DeleteParameterCommand({
|
|
163
|
-
Name: ssmPath(ctx, name),
|
|
164
|
-
}),
|
|
165
|
-
);
|
|
166
|
-
success(`${name} removed from stage "${ctx.stage}"`);
|
|
167
|
-
} catch (err: any) {
|
|
168
|
-
if (err.name === 'ParameterNotFound') {
|
|
169
|
-
warn(`Secret "${name}" not found in stage "${ctx.stage}" (already removed?)`);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
throw err;
|
|
103
|
+
const secrets = await loadSstSecrets(ctx);
|
|
104
|
+
if (!(name in secrets)) {
|
|
105
|
+
warn(`Secret "${name}" not found in stage "${ctx.stage}" (already removed?)`);
|
|
106
|
+
return;
|
|
173
107
|
}
|
|
108
|
+
|
|
109
|
+
delete secrets[name];
|
|
110
|
+
await putSstSecrets(ctx, secrets);
|
|
111
|
+
|
|
112
|
+
success(`${name} removed from stage "${ctx.stage}"`);
|
|
174
113
|
}
|
|
175
114
|
|
|
176
115
|
// ---------------------------------------------------------------------------
|
|
@@ -242,14 +181,14 @@ export async function secretsCommand(
|
|
|
242
181
|
}
|
|
243
182
|
} catch (err: any) {
|
|
244
183
|
fail(err.message);
|
|
245
|
-
info('Ensure your IAM user/role has ssm
|
|
184
|
+
info('Ensure your IAM user/role has ssm:GetParameter, s3:GetObject, and kms:Decrypt permissions.');
|
|
246
185
|
process.exit(1);
|
|
247
186
|
}
|
|
248
187
|
}
|
|
249
188
|
|
|
250
189
|
function printSecretsHelp(): void {
|
|
251
190
|
console.log(`
|
|
252
|
-
everystack secrets — Manage SST secrets
|
|
191
|
+
everystack secrets — Manage SST Ion secrets
|
|
253
192
|
|
|
254
193
|
Usage:
|
|
255
194
|
everystack secrets list --stage <name> List all secret names
|
|
@@ -264,7 +203,7 @@ Examples:
|
|
|
264
203
|
everystack secrets remove OLD_KEY --stage dev
|
|
265
204
|
|
|
266
205
|
Interop:
|
|
267
|
-
Secrets are stored
|
|
268
|
-
Both tools read and write the same values — full interop
|
|
206
|
+
Secrets are stored in the SST Ion S3 state backend with AES-256-GCM encryption.
|
|
207
|
+
Both tools read and write the same values — full interop with \`sst secret\`.
|
|
269
208
|
`);
|
|
270
209
|
}
|
|
@@ -9,8 +9,8 @@ import { resolveConfig, type CliConfig } from '../config.js';
|
|
|
9
9
|
import { uploadToS3, invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, warn, fail, info } from '../output.js';
|
|
11
11
|
import { exportApp } from '../utils/export.js';
|
|
12
|
-
import { loadSstSecrets } from '../utils/secrets.js';
|
|
13
12
|
import { walkDirectory } from '../utils/walk.js';
|
|
13
|
+
import { loadSstSecrets } from '../utils/secrets.js';
|
|
14
14
|
|
|
15
15
|
export interface UpdateFlags {
|
|
16
16
|
channel?: string;
|
|
@@ -60,31 +60,27 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
60
60
|
// Stage may not be deployed yet — HOST_URL falls back to loadSstOutputs
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
63
|
+
// Inject SST secrets into process.env so app.config.js / Metro can read them.
|
|
64
|
+
// The CLI injects ALL secrets — the app's deny-by-default filter (e.g.
|
|
65
|
+
// expo-secrets.config.js) is the security boundary that gates what reaches
|
|
66
|
+
// the bundle. This avoids requiring `sst shell` in deploy scripts.
|
|
67
67
|
try {
|
|
68
|
-
step('Loading SST secrets...');
|
|
69
68
|
const { parseAppName } = await import('../discover.js');
|
|
70
69
|
const appName = await parseAppName();
|
|
71
|
-
const region = resolvedConfig?.region
|
|
72
|
-
|
|
70
|
+
const region = resolvedConfig?.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
71
|
+
step('Loading SST secrets...');
|
|
73
72
|
const secrets = await loadSstSecrets({ appName, stage: flags.stage, region });
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
process.env[
|
|
73
|
+
let injected = 0;
|
|
74
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
75
|
+
if (!process.env[k]) {
|
|
76
|
+
process.env[k] = v;
|
|
77
|
+
injected++;
|
|
78
78
|
}
|
|
79
|
-
success(`Loaded ${names.length} secrets: ${names.join(', ')}`);
|
|
80
|
-
} else {
|
|
81
|
-
info('No SST secrets found for this stage');
|
|
82
79
|
}
|
|
80
|
+
success(`${injected} secret(s) injected into env (${Object.keys(secrets).length} total)`);
|
|
83
81
|
} catch (err: any) {
|
|
84
|
-
// Secret loading is best-effort — the user may not have SSM permissions
|
|
85
|
-
// or may not use SST secrets (e.g. env vars set manually).
|
|
86
82
|
warn(`Could not load SST secrets: ${err.message}`);
|
|
87
|
-
info('
|
|
83
|
+
info('Secrets may be unavailable during export. Provide env vars from the caller if needed.');
|
|
88
84
|
}
|
|
89
85
|
}
|
|
90
86
|
|
|
@@ -98,21 +94,6 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
98
94
|
const appConfig = await loadAppConfig();
|
|
99
95
|
const runtimeVersion = appConfig.runtimeVersion;
|
|
100
96
|
|
|
101
|
-
// Validate: SSR flags in the expo-router plugin config will crash native devices.
|
|
102
|
-
// The bundle is compiled with server rendering code paths that have no server at runtime.
|
|
103
|
-
const ssrFlags = getExpoRouterSsrFlags(appConfig);
|
|
104
|
-
if (ssrFlags.useServerRendering || ssrFlags.useServerDataLoaders) {
|
|
105
|
-
fail('Export contains server rendering flags that will crash native devices.');
|
|
106
|
-
info('expo-router plugin has unstable_useServerRendering or unstable_useServerDataLoaders enabled.');
|
|
107
|
-
info('Use SSR_ENABLED to opt in only for web server exports:');
|
|
108
|
-
info('');
|
|
109
|
-
info(" ['expo-router', {");
|
|
110
|
-
info(' unstable_useServerRendering: !!process.env.SSR_ENABLED,');
|
|
111
|
-
info(' unstable_useServerDataLoaders: !!process.env.SSR_ENABLED,');
|
|
112
|
-
info(' }]');
|
|
113
|
-
process.exit(1);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
97
|
if (!runtimeVersion) {
|
|
117
98
|
fail('No runtimeVersion found in app.json/app.config.js');
|
|
118
99
|
info('Add "runtimeVersion" to your app.json, e.g.:');
|
|
@@ -125,6 +106,23 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
125
106
|
const distDir = path.resolve('dist');
|
|
126
107
|
const needsMobile = platformFilter !== 'web';
|
|
127
108
|
|
|
109
|
+
// Validate: SSR flags in the expo-router plugin config will crash native devices.
|
|
110
|
+
// Only check when mobile platforms are involved — web-only updates want SSR enabled.
|
|
111
|
+
if (needsMobile) {
|
|
112
|
+
const ssrFlags = getExpoRouterSsrFlags(appConfig);
|
|
113
|
+
if (ssrFlags.useServerRendering || ssrFlags.useServerDataLoaders) {
|
|
114
|
+
fail('Export contains server rendering flags that will crash native devices.');
|
|
115
|
+
info('expo-router plugin has unstable_useServerRendering or unstable_useServerDataLoaders enabled.');
|
|
116
|
+
info('Use SSR_ENABLED to opt in only for web server exports:');
|
|
117
|
+
info('');
|
|
118
|
+
info(" ['expo-router', {");
|
|
119
|
+
info(' unstable_useServerRendering: !!process.env.SSR_ENABLED,');
|
|
120
|
+
info(' unstable_useServerDataLoaders: !!process.env.SSR_ENABLED,');
|
|
121
|
+
info(' }]');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
128
126
|
// First export: mobile-safe bundles (no SSR_ENABLED).
|
|
129
127
|
// Always re-export — stale dist/ is the #1 cause of broken OTA pushes.
|
|
130
128
|
if (needsMobile) {
|
package/src/cli/utils/secrets.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Load SST secrets from
|
|
2
|
+
* Load SST Ion secrets from S3 state backend.
|
|
3
3
|
*
|
|
4
|
-
* SST
|
|
5
|
-
*
|
|
4
|
+
* SST Ion stores secrets as AES-256-GCM encrypted JSON in S3:
|
|
5
|
+
* Bucket: sst-state-{random} (from /sst/bootstrap SSM parameter)
|
|
6
|
+
* Key: secret/{appName}/{stage}.json
|
|
6
7
|
*
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* The encryption passphrase is stored in SSM at:
|
|
9
|
+
* /sst/passphrase/{appName}/{stage} (SecureString)
|
|
10
|
+
*
|
|
11
|
+
* Flow: fetch passphrase from SSM → fetch encrypted blob from S3 → AES-GCM decrypt → JSON parse.
|
|
12
|
+
*
|
|
13
|
+
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, kms:Decrypt.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { gunzipSync } from 'node:zlib';
|
|
18
|
+
|
|
12
19
|
export interface LoadSecretsOptions {
|
|
13
20
|
appName: string;
|
|
14
21
|
stage: string;
|
|
@@ -16,49 +23,173 @@ export interface LoadSecretsOptions {
|
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
|
-
*
|
|
26
|
+
* Resolve the SST state bucket name from the /sst/bootstrap SSM parameter.
|
|
27
|
+
*/
|
|
28
|
+
async function getStateBucket(region: string): Promise<string> {
|
|
29
|
+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
30
|
+
const client = new SSMClient({ region });
|
|
31
|
+
const result = await client.send(
|
|
32
|
+
new GetParameterCommand({
|
|
33
|
+
Name: '/sst/bootstrap',
|
|
34
|
+
WithDecryption: false,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
if (!result.Parameter?.Value) {
|
|
38
|
+
throw new Error('SST bootstrap not found — has `sst deploy` been run?');
|
|
39
|
+
}
|
|
40
|
+
const data = JSON.parse(result.Parameter.Value);
|
|
41
|
+
if (!data.state) {
|
|
42
|
+
throw new Error('SST bootstrap missing state bucket');
|
|
43
|
+
}
|
|
44
|
+
return data.state as string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch the passphrase for a given app + stage from SSM.
|
|
49
|
+
*/
|
|
50
|
+
async function getPassphrase(region: string, appName: string, stage: string): Promise<string> {
|
|
51
|
+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
52
|
+
const client = new SSMClient({ region });
|
|
53
|
+
const result = await client.send(
|
|
54
|
+
new GetParameterCommand({
|
|
55
|
+
Name: `/sst/passphrase/${appName}/${stage}`,
|
|
56
|
+
WithDecryption: true,
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
if (!result.Parameter?.Value) {
|
|
60
|
+
throw new Error(`No SST passphrase found for ${appName}/${stage}`);
|
|
61
|
+
}
|
|
62
|
+
return result.Parameter.Value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch the encrypted secrets blob from S3.
|
|
67
|
+
*/
|
|
68
|
+
async function getSecretBlob(
|
|
69
|
+
region: string,
|
|
70
|
+
bucket: string,
|
|
71
|
+
appName: string,
|
|
72
|
+
stage: string,
|
|
73
|
+
): Promise<Buffer | null> {
|
|
74
|
+
const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
75
|
+
const client = new S3Client({ region });
|
|
76
|
+
try {
|
|
77
|
+
const result = await client.send(
|
|
78
|
+
new GetObjectCommand({
|
|
79
|
+
Bucket: bucket,
|
|
80
|
+
Key: `secret/${appName}/${stage}.json`,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
if (!result.Body) return null;
|
|
84
|
+
const bytes = await result.Body.transformToByteArray();
|
|
85
|
+
return Buffer.from(bytes);
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decrypt AES-256-GCM ciphertext using a base64-encoded passphrase.
|
|
96
|
+
*
|
|
97
|
+
* SST Ion format: nonce (12 bytes) + ciphertext + auth tag (16 bytes appended by GCM Seal).
|
|
98
|
+
* Go's gcm.Seal() appends the tag to the ciphertext, so we split accordingly.
|
|
99
|
+
*/
|
|
100
|
+
function decrypt(encrypted: Buffer, passphrase: string): Buffer {
|
|
101
|
+
const key = Buffer.from(passphrase, 'base64');
|
|
102
|
+
const nonceSize = 12; // AES-GCM standard nonce size
|
|
103
|
+
const tagSize = 16; // AES-GCM auth tag size
|
|
104
|
+
|
|
105
|
+
if (encrypted.length < nonceSize + tagSize) {
|
|
106
|
+
throw new Error('Encrypted data too short');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const nonce = encrypted.subarray(0, nonceSize);
|
|
110
|
+
const ciphertextWithTag = encrypted.subarray(nonceSize);
|
|
111
|
+
const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - tagSize);
|
|
112
|
+
const tag = ciphertextWithTag.subarray(ciphertextWithTag.length - tagSize);
|
|
113
|
+
|
|
114
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
115
|
+
decipher.setAuthTag(tag);
|
|
116
|
+
|
|
117
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
118
|
+
return decrypted;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fetch all SST Ion secrets for a given app + stage.
|
|
20
123
|
*
|
|
21
124
|
* Returns a map of { SECRET_NAME: decrypted_value }.
|
|
22
|
-
* Paginates automatically — SSM returns max 10 parameters per page.
|
|
23
125
|
*
|
|
24
|
-
* Requires IAM permissions: ssm:
|
|
126
|
+
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, kms:Decrypt.
|
|
25
127
|
*/
|
|
26
128
|
export async function loadSstSecrets(
|
|
27
129
|
options: LoadSecretsOptions,
|
|
28
130
|
): Promise<Record<string, string>> {
|
|
29
|
-
const {
|
|
30
|
-
'@aws-sdk/client-ssm'
|
|
31
|
-
);
|
|
131
|
+
const { appName, stage, region } = options;
|
|
32
132
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const secrets: Record<string, string> = {};
|
|
36
|
-
|
|
37
|
-
let nextToken: string | undefined;
|
|
38
|
-
do {
|
|
39
|
-
const response = await client.send(
|
|
40
|
-
new GetParametersByPathCommand({
|
|
41
|
-
Path: prefix,
|
|
42
|
-
WithDecryption: true,
|
|
43
|
-
Recursive: true,
|
|
44
|
-
NextToken: nextToken,
|
|
45
|
-
}),
|
|
46
|
-
);
|
|
133
|
+
// 1. Get the state bucket name from /sst/bootstrap
|
|
134
|
+
const bucket = await getStateBucket(region);
|
|
47
135
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
136
|
+
// 2. Get the passphrase from SSM
|
|
137
|
+
const passphrase = await getPassphrase(region, appName, stage);
|
|
138
|
+
|
|
139
|
+
// 3. Fetch the encrypted secrets blob from S3
|
|
140
|
+
let blob = await getSecretBlob(region, bucket, appName, stage);
|
|
141
|
+
if (!blob) {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Try gzip decompress (SST may compress the data)
|
|
146
|
+
try {
|
|
147
|
+
blob = gunzipSync(blob);
|
|
148
|
+
} catch {
|
|
149
|
+
// Not gzipped, use raw
|
|
150
|
+
}
|
|
59
151
|
|
|
60
|
-
|
|
61
|
-
|
|
152
|
+
// 5. Decrypt with AES-256-GCM
|
|
153
|
+
const decrypted = decrypt(blob, passphrase);
|
|
62
154
|
|
|
155
|
+
// 6. Parse the JSON map
|
|
156
|
+
const secrets: Record<string, string> = JSON.parse(decrypted.toString('utf8'));
|
|
63
157
|
return secrets;
|
|
64
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Encrypt and store secrets to the SST Ion S3 state backend.
|
|
162
|
+
*/
|
|
163
|
+
export async function putSstSecrets(
|
|
164
|
+
options: LoadSecretsOptions,
|
|
165
|
+
secrets: Record<string, string>,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const { appName, stage, region } = options;
|
|
168
|
+
|
|
169
|
+
const bucket = await getStateBucket(region);
|
|
170
|
+
const passphrase = await getPassphrase(region, appName, stage);
|
|
171
|
+
|
|
172
|
+
// Encrypt with AES-256-GCM (same format as SST Ion's Go code)
|
|
173
|
+
const key = Buffer.from(passphrase, 'base64');
|
|
174
|
+
const nonce = crypto.randomBytes(12);
|
|
175
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
176
|
+
|
|
177
|
+
const jsonBytes = Buffer.from(JSON.stringify(secrets), 'utf8');
|
|
178
|
+
const encrypted = Buffer.concat([cipher.update(jsonBytes), cipher.final()]);
|
|
179
|
+
const tag = cipher.getAuthTag();
|
|
180
|
+
|
|
181
|
+
// Format: nonce + ciphertext + tag (matches Go's gcm.Seal output)
|
|
182
|
+
const blob = Buffer.concat([nonce, encrypted, tag]);
|
|
183
|
+
|
|
184
|
+
// Write to S3
|
|
185
|
+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
186
|
+
const client = new S3Client({ region });
|
|
187
|
+
await client.send(
|
|
188
|
+
new PutObjectCommand({
|
|
189
|
+
Bucket: bucket,
|
|
190
|
+
Key: `secret/${appName}/${stage}.json`,
|
|
191
|
+
Body: blob,
|
|
192
|
+
ContentType: 'application/json',
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}
|