@everystack/cli 0.2.12 → 0.2.14
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 +6 -1
- package/src/cli/commands/secrets.ts +209 -0
- package/src/cli/commands/update.ts +45 -1
- package/src/cli/index.ts +21 -0
- package/src/cli/utils/secrets.ts +195 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
55
55
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
56
56
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
57
|
+
"@aws-sdk/client-ssm": "3.1053.0",
|
|
57
58
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
58
59
|
"drizzle-orm": "0.41.0",
|
|
59
60
|
"expo-updates": "55.0.21",
|
|
@@ -73,6 +74,9 @@
|
|
|
73
74
|
"@aws-sdk/client-lambda": {
|
|
74
75
|
"optional": true
|
|
75
76
|
},
|
|
77
|
+
"@aws-sdk/client-ssm": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
76
80
|
"expo-updates": {
|
|
77
81
|
"optional": true
|
|
78
82
|
},
|
|
@@ -90,6 +94,7 @@
|
|
|
90
94
|
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
91
95
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
92
96
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
97
|
+
"@aws-sdk/client-ssm": "3.1053.0",
|
|
93
98
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
94
99
|
"@types/jest": "29.5.14",
|
|
95
100
|
"@types/node": "22.19.18",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets management — read/write SST Ion secrets.
|
|
3
|
+
*
|
|
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
|
|
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.
|
|
11
|
+
* Secrets set via `everystack secrets set` are readable by SST and vice versa.
|
|
12
|
+
*
|
|
13
|
+
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, s3:PutObject, kms:Decrypt.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
17
|
+
import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
|
|
18
|
+
|
|
19
|
+
interface SecretsContext {
|
|
20
|
+
appName: string;
|
|
21
|
+
stage: string;
|
|
22
|
+
region: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function resolveContext(flags: Record<string, string>): Promise<SecretsContext> {
|
|
26
|
+
const stage = flags.stage;
|
|
27
|
+
if (!stage) {
|
|
28
|
+
fail('--stage is required for secrets management');
|
|
29
|
+
info('Usage: everystack secrets list --stage <name>');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { parseAppName } = await import('../discover.js');
|
|
34
|
+
const appName = await parseAppName();
|
|
35
|
+
const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
36
|
+
|
|
37
|
+
return { appName, stage, region };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// List
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
async function listSecrets(ctx: SecretsContext): Promise<void> {
|
|
45
|
+
step(`Loading secrets for ${ctx.appName}/${ctx.stage}...`);
|
|
46
|
+
|
|
47
|
+
const secrets = await loadSstSecrets(ctx);
|
|
48
|
+
const names = Object.keys(secrets).sort();
|
|
49
|
+
|
|
50
|
+
if (names.length === 0) {
|
|
51
|
+
info('No secrets found');
|
|
52
|
+
info(`Set one with: everystack secrets set KEY "value" --stage ${ctx.stage}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
success(`${names.length} secret(s):`);
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
console.log(` ${name}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Get
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
async function getSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
67
|
+
step(`Reading ${name}...`);
|
|
68
|
+
|
|
69
|
+
const secrets = await loadSstSecrets(ctx);
|
|
70
|
+
const value = secrets[name];
|
|
71
|
+
|
|
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);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Set
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
async function setSecret(ctx: SecretsContext, name: string, value: string): Promise<void> {
|
|
86
|
+
step(`Setting ${name}...`);
|
|
87
|
+
|
|
88
|
+
// Load existing secrets, merge, and write back
|
|
89
|
+
const secrets = await loadSstSecrets(ctx);
|
|
90
|
+
secrets[name] = value;
|
|
91
|
+
await putSstSecrets(ctx, secrets);
|
|
92
|
+
|
|
93
|
+
success(`${name} set for stage "${ctx.stage}"`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Remove
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async function removeSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
101
|
+
step(`Removing ${name}...`);
|
|
102
|
+
|
|
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;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
delete secrets[name];
|
|
110
|
+
await putSstSecrets(ctx, secrets);
|
|
111
|
+
|
|
112
|
+
success(`${name} removed from stage "${ctx.stage}"`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Command entry point
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export async function secretsCommand(
|
|
120
|
+
subcommand: string | undefined,
|
|
121
|
+
positionalArgs: string[],
|
|
122
|
+
flags: Record<string, string>,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
125
|
+
printSecretsHelp();
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let ctx: SecretsContext;
|
|
130
|
+
try {
|
|
131
|
+
ctx = await resolveContext(flags);
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
fail(err.message);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
switch (subcommand) {
|
|
139
|
+
case 'list':
|
|
140
|
+
await listSecrets(ctx);
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
case 'get': {
|
|
144
|
+
const name = positionalArgs[0];
|
|
145
|
+
if (!name) {
|
|
146
|
+
fail('Missing secret name');
|
|
147
|
+
info('Usage: everystack secrets get <name> --stage <stage>');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
await getSecret(ctx, name);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case 'set': {
|
|
155
|
+
const name = positionalArgs[0];
|
|
156
|
+
const value = positionalArgs[1];
|
|
157
|
+
if (!name || value === undefined) {
|
|
158
|
+
fail('Missing secret name or value');
|
|
159
|
+
info('Usage: everystack secrets set <name> <value> --stage <stage>');
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
await setSecret(ctx, name, value);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'remove': {
|
|
167
|
+
const name = positionalArgs[0];
|
|
168
|
+
if (!name) {
|
|
169
|
+
fail('Missing secret name');
|
|
170
|
+
info('Usage: everystack secrets remove <name> --stage <stage>');
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
await removeSecret(ctx, name);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
fail(`Unknown secrets subcommand: ${subcommand}`);
|
|
179
|
+
printSecretsHelp();
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
fail(err.message);
|
|
184
|
+
info('Ensure your IAM user/role has ssm:GetParameter, s3:GetObject, and kms:Decrypt permissions.');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function printSecretsHelp(): void {
|
|
190
|
+
console.log(`
|
|
191
|
+
everystack secrets — Manage SST Ion secrets
|
|
192
|
+
|
|
193
|
+
Usage:
|
|
194
|
+
everystack secrets list --stage <name> List all secret names
|
|
195
|
+
everystack secrets get <name> --stage <name> Print a secret value
|
|
196
|
+
everystack secrets set <name> <value> --stage <name> Set or update a secret
|
|
197
|
+
everystack secrets remove <name> --stage <name> Delete a secret
|
|
198
|
+
|
|
199
|
+
Examples:
|
|
200
|
+
everystack secrets list --stage production
|
|
201
|
+
everystack secrets set MAPBOX_API_KEY "pk.abc123" --stage production
|
|
202
|
+
everystack secrets get JWT_SECRET --stage dev
|
|
203
|
+
everystack secrets remove OLD_KEY --stage dev
|
|
204
|
+
|
|
205
|
+
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\`.
|
|
208
|
+
`);
|
|
209
|
+
}
|
|
@@ -9,6 +9,7 @@ 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';
|
|
12
13
|
import { walkDirectory } from '../utils/walk.js';
|
|
13
14
|
|
|
14
15
|
export interface UpdateFlags {
|
|
@@ -24,7 +25,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
24
25
|
// Signal publish context so app.config.js can detect it's not a dev session.
|
|
25
26
|
// Without this, dotenv guards that skip .env.local for EAS_BUILD/EAS_UPDATE
|
|
26
27
|
// don't know to skip for everystack update, causing local dev overrides
|
|
27
|
-
// (e.g.
|
|
28
|
+
// (e.g. localhost overrides → LAN IP) to leak into the manifest.
|
|
28
29
|
process.env.EVERYSTACK_UPDATE = '1';
|
|
29
30
|
|
|
30
31
|
// Resolve ENVIRONMENT before app.config.js evaluation so the manifest
|
|
@@ -58,6 +59,49 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
58
59
|
} catch {
|
|
59
60
|
// Stage may not be deployed yet — HOST_URL falls back to loadSstOutputs
|
|
60
61
|
}
|
|
62
|
+
|
|
63
|
+
// Load EXPO_PUBLIC_* secrets for this stage and inject into process.env.
|
|
64
|
+
// Only public secrets are injected — server secrets (DATABASE_URL, JWT_SECRET, etc.)
|
|
65
|
+
// are provided by SST Resource linking at Lambda runtime.
|
|
66
|
+
// app.config.js merges { ...easInfo, ...process.env }, so these win over .env.local.
|
|
67
|
+
try {
|
|
68
|
+
step('Loading SST secrets...');
|
|
69
|
+
const { parseAppName } = await import('../discover.js');
|
|
70
|
+
const appName = await parseAppName();
|
|
71
|
+
const region = resolvedConfig?.region
|
|
72
|
+
|| process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
73
|
+
const secrets = await loadSstSecrets({ appName, stage: flags.stage, region });
|
|
74
|
+
const allNames = Object.keys(secrets);
|
|
75
|
+
const publicSecrets = Object.entries(secrets).filter(([k]) => k.startsWith('EXPO_PUBLIC_'));
|
|
76
|
+
const skipped = allNames.length - publicSecrets.length;
|
|
77
|
+
if (publicSecrets.length > 0) {
|
|
78
|
+
for (const [key, value] of publicSecrets) {
|
|
79
|
+
process.env[key] ||= value;
|
|
80
|
+
}
|
|
81
|
+
success(`Injected ${publicSecrets.length} EXPO_PUBLIC_* secrets: ${publicSecrets.map(([k]) => k).join(', ')}`);
|
|
82
|
+
} else if (allNames.length > 0) {
|
|
83
|
+
info('No EXPO_PUBLIC_* secrets found (server secrets are provided by SST at runtime)');
|
|
84
|
+
} else {
|
|
85
|
+
info('No SST secrets found for this stage');
|
|
86
|
+
}
|
|
87
|
+
if (skipped > 0) {
|
|
88
|
+
info(`Skipped ${skipped} server secret(s) — provided by SST Resource linking at Lambda runtime`);
|
|
89
|
+
}
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
// Secret loading is best-effort — the user may not have permissions
|
|
92
|
+
// or may not use SST secrets (e.g. env vars set manually).
|
|
93
|
+
const msg = err.message || String(err);
|
|
94
|
+
if (msg.includes("Cannot find package '@aws-sdk/client-ssm'")) {
|
|
95
|
+
warn('Secrets skipped: @aws-sdk/client-ssm not installed');
|
|
96
|
+
info('Install with: pnpm add -D @aws-sdk/client-ssm');
|
|
97
|
+
} else if (msg.includes("Cannot find package '@aws-sdk/client-s3'")) {
|
|
98
|
+
warn('Secrets skipped: @aws-sdk/client-s3 not installed');
|
|
99
|
+
info('Install with: pnpm add -D @aws-sdk/client-s3');
|
|
100
|
+
} else {
|
|
101
|
+
warn(`Could not load SST secrets: ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
info('Set secrets with: everystack secrets set KEY "value" --stage ' + flags.stage);
|
|
104
|
+
}
|
|
61
105
|
}
|
|
62
106
|
|
|
63
107
|
const branch = flags.branch;
|
package/src/cli/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { cachePurgeCommand } from './commands/cache.js';
|
|
|
12
12
|
import { diagCommand } from './commands/diag.js';
|
|
13
13
|
import { analyzeSSRCommand } from './commands/analyze.js';
|
|
14
14
|
import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
|
|
15
|
+
import { secretsCommand } from './commands/secrets.js';
|
|
15
16
|
import { fail } from './output.js';
|
|
16
17
|
|
|
17
18
|
const args = process.argv.slice(2);
|
|
@@ -138,6 +139,22 @@ async function main() {
|
|
|
138
139
|
case 'logs:query':
|
|
139
140
|
await logsQueryCommand(flags);
|
|
140
141
|
break;
|
|
142
|
+
case 'secrets': {
|
|
143
|
+
// secrets <subcommand> [positional...] [--flags]
|
|
144
|
+
// Extract positional args: everything after subcommand that isn't a flag or flag value
|
|
145
|
+
const sub = args[1] && !args[1].startsWith('-') ? args[1] : undefined;
|
|
146
|
+
const positional: string[] = [];
|
|
147
|
+
for (let i = 2; i < args.length; i++) {
|
|
148
|
+
if (args[i].startsWith('-')) {
|
|
149
|
+
// Skip flag and its value
|
|
150
|
+
if (args[i + 1] && !args[i + 1].startsWith('-')) i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
positional.push(args[i]);
|
|
154
|
+
}
|
|
155
|
+
await secretsCommand(sub, positional, flags);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
141
158
|
default:
|
|
142
159
|
fail(`Unknown command: ${command}`);
|
|
143
160
|
printHelp();
|
|
@@ -175,6 +192,10 @@ Usage:
|
|
|
175
192
|
everystack channels list
|
|
176
193
|
everystack channels create --name <name> [--branch <name>]
|
|
177
194
|
everystack channels edit --name <name> --branch <name>
|
|
195
|
+
everystack secrets list --stage <name> List all secret names
|
|
196
|
+
everystack secrets get <name> --stage <name> Print a secret value
|
|
197
|
+
everystack secrets set <name> <value> --stage <name> Set or update a secret
|
|
198
|
+
everystack secrets remove <name> --stage <name> Delete a secret
|
|
178
199
|
|
|
179
200
|
Stage resolution:
|
|
180
201
|
--stage <name> Discover AWS resources for the given stage by querying AWS APIs.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load SST Ion secrets from S3 state backend.
|
|
3
|
+
*
|
|
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
|
|
7
|
+
*
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { gunzipSync } from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
export interface LoadSecretsOptions {
|
|
20
|
+
appName: string;
|
|
21
|
+
stage: string;
|
|
22
|
+
region: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
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.
|
|
123
|
+
*
|
|
124
|
+
* Returns a map of { SECRET_NAME: decrypted_value }.
|
|
125
|
+
*
|
|
126
|
+
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, kms:Decrypt.
|
|
127
|
+
*/
|
|
128
|
+
export async function loadSstSecrets(
|
|
129
|
+
options: LoadSecretsOptions,
|
|
130
|
+
): Promise<Record<string, string>> {
|
|
131
|
+
const { appName, stage, region } = options;
|
|
132
|
+
|
|
133
|
+
// 1. Get the state bucket name from /sst/bootstrap
|
|
134
|
+
const bucket = await getStateBucket(region);
|
|
135
|
+
|
|
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
|
+
}
|
|
151
|
+
|
|
152
|
+
// 5. Decrypt with AES-256-GCM
|
|
153
|
+
const decrypted = decrypt(blob, passphrase);
|
|
154
|
+
|
|
155
|
+
// 6. Parse the JSON map
|
|
156
|
+
const secrets: Record<string, string> = JSON.parse(decrypted.toString('utf8'));
|
|
157
|
+
return secrets;
|
|
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
|
+
}
|