Skip to content

Commit 2fd8c6a

Browse files
adg-flareLukaAvbreht
authored andcommitted
fix(FLR-FDC-MINT-002): harden web2 ignite key decryption flow
1 parent d2ea985 commit 2fd8c6a

4 files changed

Lines changed: 166 additions & 195 deletions

File tree

.gitignore

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,8 @@ lerna-debug.log*
3737

3838
# dotenv environment variable files
3939
.env
40-
.env.development.local
41-
.env.test.local
42-
.env.production.local
43-
.env.local*
40+
.env.*
41+
!**/.env.example
4442

4543
# temp directory
4644
.temp

scripts/web2/README.md

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,24 @@ Each key is encrypted with that provider's signing policy public key and then pu
77

88
The steps below show how to decrypt and obtain your own provider's API key.
99

10-
## Setup
11-
12-
From the project root run:
10+
## Step 1: Fetch encrypted keys
1311

1412
```bash
15-
pnpm install
13+
curl -s "https://api.ignitemarket.xyz/proxy-api-keys" -o scripts/web2/ignite-api-keys.json
1614
```
1715

18-
Create a `.env.decrypt` file in `scripts/web2/` with:
19-
16+
For Songbird:
2017
```bash
21-
PRIVATE_KEY=0x...
22-
```
23-
24-
- `PRIVATE_KEY`: your provider's signing policy private key in hex format.
25-
26-
## Step 1: Fetch encrypted keys
27-
28-
From `scripts/web2/`, run:
29-
30-
```bash
31-
curl -s "https://api.ignitemarket.xyz/proxy-api-keys" -o ignite-api-keys.json
18+
curl -s "https://api.ignitemarket.xyz/proxy-api-keys/songbird" -o scripts/web2/ignite-api-keys.json
3219
```
3320

3421
## Step 2: Decrypt your API key
3522

36-
From `scripts/web2/`, run:
37-
3823
```bash
39-
npx ts-node decrypt-ignite-key.ts
24+
node --permission --allow-fs-read='./scripts/web2/*' scripts/web2/decrypt-ignite-key.mjs
4025
```
4126

42-
This will:
27+
The script prompts for your signing policy private key with hidden input. The key is not stored in shell history or environment variables.
28+
The `--permission` flag (Node 22+) blocks all network access, child processes, and filesystem writes — only reads from the script's directory are allowed.
4329

44-
1. Load `PRIVATE_KEY` from `.env.decrypt`.
45-
2. Read the full keys JSON file (`ignite-api-keys.json`).
46-
3. Filter find entry matching your signing policy address derived from `PRIVATE_KEY`.
47-
4. Decrypt the API key.
48-
5. Print the decrypted API key to stdout.
30+
The script uses only Node.js built-in modules (`node:crypto`, `node:fs`) and requires no `pnpm install`.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env node
2+
import { createDecipheriv, createECDH, hkdfSync } from 'node:crypto';
3+
import { readFileSync } from 'node:fs';
4+
import { dirname, resolve } from 'node:path';
5+
import { stdin, stdout } from 'node:process';
6+
import { fileURLToPath } from 'node:url';
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
10+
const ECDH_SALT = Buffer.from('key-publish-ecdh-salt-v1');
11+
const ECDH_INFO_PREFIX = Buffer.from('key-publish-api-key:v1:');
12+
const AES_NONCE_SIZE = 12;
13+
const AUTH_TAG_SIZE = 16;
14+
15+
async function promptHidden(prompt) {
16+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function') {
17+
throw new Error(
18+
'Interactive TTY required. Run this script from a terminal session.',
19+
);
20+
}
21+
22+
stdout.write(prompt);
23+
stdin.setEncoding('utf8');
24+
stdin.setRawMode(true);
25+
stdin.resume();
26+
27+
return await new Promise((resolve, reject) => {
28+
let value = '';
29+
30+
const cleanup = () => {
31+
stdin.removeListener('data', onData);
32+
stdin.setRawMode(false);
33+
stdin.pause();
34+
};
35+
36+
const onData = (chunk) => {
37+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
38+
for (const ch of text) {
39+
if (ch === '\r' || ch === '\n') {
40+
cleanup();
41+
stdout.write('\n');
42+
resolve(value);
43+
return;
44+
}
45+
if (ch === '\u0003') {
46+
cleanup();
47+
stdout.write('\n');
48+
reject(new Error('Input cancelled by user.'));
49+
return;
50+
}
51+
if (ch === '\u007f' || ch === '\b' || ch === '\x08') {
52+
if (value.length > 0) {
53+
value = value.slice(0, -1);
54+
}
55+
continue;
56+
}
57+
value += ch;
58+
}
59+
};
60+
61+
stdin.on('data', onData);
62+
});
63+
}
64+
65+
async function loadPrivateKey() {
66+
const raw = await promptHidden('Signing policy private key: ');
67+
let s = raw.trim().toLowerCase();
68+
if (s.startsWith('0x')) s = s.slice(2);
69+
if (!/^[0-9a-f]{64}$/.test(s)) {
70+
throw new Error('PRIVATE_KEY must be a 32-byte hex string.');
71+
}
72+
return Buffer.from(s, 'hex');
73+
}
74+
75+
function decrypt(privateKey, signingAddress, encryptedB64) {
76+
const payload = Buffer.from(encryptedB64, 'base64');
77+
if (payload.length <= 65 + AES_NONCE_SIZE + AUTH_TAG_SIZE) {
78+
throw new Error('Encrypted payload too short');
79+
}
80+
81+
const ephPub = payload.subarray(0, 65);
82+
const nonce = payload.subarray(65, 65 + AES_NONCE_SIZE);
83+
const ciphertext = payload.subarray(65 + AES_NONCE_SIZE, -AUTH_TAG_SIZE);
84+
const authTag = payload.subarray(-AUTH_TAG_SIZE);
85+
86+
const ecdh = createECDH('secp256k1');
87+
ecdh.setPrivateKey(privateKey);
88+
const shared = ecdh.computeSecret(ephPub);
89+
let aesKey;
90+
91+
try {
92+
const info = Buffer.concat([
93+
ECDH_INFO_PREFIX,
94+
Buffer.from(signingAddress, 'utf8'),
95+
]);
96+
aesKey = Buffer.from(hkdfSync('sha256', shared, ECDH_SALT, info, 32));
97+
98+
const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce);
99+
decipher.setAuthTag(authTag);
100+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
101+
'utf8',
102+
);
103+
} finally {
104+
shared.fill(0);
105+
if (aesKey) {
106+
aesKey.fill(0);
107+
}
108+
}
109+
}
110+
111+
// Derive Ethereum address requires keccak-256 which Node.js doesn't expose,
112+
// so we try decrypting each record until one's auth tag validates.
113+
function findAndDecrypt(records, privateKey) {
114+
for (const item of records) {
115+
const addr = item?.signing_policy_address?.toString();
116+
const enc = item?.encrypted_API_key?.toString();
117+
if (!addr || !enc) continue;
118+
119+
try {
120+
return { signingAddress: addr, apiKey: decrypt(privateKey, addr, enc) };
121+
} catch {
122+
// Auth tag mismatch — not our record, keep going.
123+
}
124+
}
125+
return undefined;
126+
}
127+
128+
async function main() {
129+
let privateKey;
130+
try {
131+
privateKey = await loadPrivateKey();
132+
const file = readFileSync(resolve(__dirname, 'ignite-api-keys.json'), 'utf8');
133+
const records = JSON.parse(file)?.data;
134+
135+
if (!Array.isArray(records)) {
136+
throw new Error('ignite-api-keys.json does not contain a "data" array.');
137+
}
138+
139+
const match = findAndDecrypt(records, privateKey);
140+
if (!match) {
141+
throw new Error('No decryptable entry found for this private key.');
142+
}
143+
144+
console.log(`Signing policy address: ${match.signingAddress}`);
145+
console.log(match.apiKey);
146+
} catch (err) {
147+
console.error(err.message);
148+
process.exitCode = 1;
149+
} finally {
150+
if (privateKey) {
151+
privateKey.fill(0);
152+
}
153+
}
154+
}
155+
156+
void main();

scripts/web2/decrypt-ignite-key.ts

Lines changed: 0 additions & 165 deletions
This file was deleted.

0 commit comments

Comments
 (0)