Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,668 changes: 161 additions & 1,507 deletions package-lock.json

Large diffs are not rendered by default.

26 changes: 12 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,19 @@
},
"homepage": "https://github.com/LutfiGarzon/vault#readme",
"dependencies": {
"@aws-sdk/client-kms": "^3.1033.0",
"@aws-sdk/client-sts": "^3.1033.0",
"@clack/prompts": "^1.2.0",
"commander": "^14.0.3",
"dotenv": "^17.4.1",
"libsodium-wrappers": "npm:libsodium-wrappers-sumo@^0.8.3",
"libsodium-wrappers-sumo": "^0.8.3"
"@clack/prompts": "1.2.0",
"commander": "14.0.3",
"dotenv": "17.4.1",
"libsodium-wrappers": "npm:libsodium-wrappers-sumo@0.8.3",
"libsodium-wrappers-sumo": "0.8.3"
},
"devDependencies": {
"@semantic-release/git": "^10.0.1",
"@types/libsodium-wrappers": "npm:@types/libsodium-wrappers-sumo@^0.7.8",
"@types/node": "^25.6.0",
"@vitest/coverage-v8": "^4.1.4",
"semantic-release": "^24.2.9",
"typescript": "^6.0.2",
"vitest": "^4.1.4"
"@semantic-release/git": "10.0.1",
"@types/libsodium-wrappers": "npm:@types/libsodium-wrappers-sumo@0.7.8",
"@types/node": "25.6.0",
"@vitest/coverage-v8": "4.1.4",
"semantic-release": "24.2.9",
"typescript": "6.0.2",
"vitest": "4.1.4"
}
}
24 changes: 18 additions & 6 deletions src/core/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ export interface EncryptedPayload {
salt: string;
nonce: string;
ciphertext: string;
opslimit?: number;
memlimit?: number;
}

export async function deriveKey(password: string, salt: Uint8Array): Promise<Uint8Array> {
export async function deriveKey(
password: string,
salt: Uint8Array,
opslimit?: number,
memlimit?: number
): Promise<Uint8Array> {
await _sodium.ready;
const sodium = _sodium;

Expand All @@ -15,8 +22,8 @@ export async function deriveKey(password: string, salt: Uint8Array): Promise<Uin
sodium.crypto_secretbox_KEYBYTES,
password,
salt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
opslimit ?? sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
memlimit ?? sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_ARGON2ID13
);
}
Expand All @@ -36,20 +43,25 @@ export async function decryptPayload(
// Helper to encrypt for mocking/testing
export async function encryptPayload(
plaintext: string,
password: string
password: string,
opslimit?: number,
memlimit?: number
): Promise<EncryptedPayload> {
await _sodium.ready;
const sodium = _sodium;

const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const key = await deriveKey(password, salt);
const key = await deriveKey(password, salt, opslimit, memlimit);
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);

const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
sodium.memzero(key);

return {
salt: sodium.to_hex(salt),
nonce: sodium.to_hex(nonce),
ciphertext: sodium.to_hex(ciphertext)
ciphertext: sodium.to_hex(ciphertext),
...(opslimit ? { opslimit } : {}),
...(memlimit ? { memlimit } : {})
};
}
4 changes: 3 additions & 1 deletion src/core/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ export async function decryptLocalVault(payload: LocalVaultPayload, gmk: Uint8Ar
const plaintext = sodium.crypto_secretbox_open_easy(payloadCiphertext, payloadNonce, dek);

sodium.memzero(dek);
return sodium.to_string(plaintext);
const decryptedString = sodium.to_string(plaintext);
sodium.memzero(plaintext);
return decryptedString;
}

export async function generateRecoveryKey(): Promise<string> {
Expand Down
4 changes: 2 additions & 2 deletions src/core/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { storeHardwareKey, retrieveHardwareKey } from './hardware-key.js';
import { createSession, resolveSession, destroySession } from './session.js';
import { Flexoki, log } from '../features/tui/components/theme.js';

import { getLocalVaultFile, getLocalVaultPath } from './vault-file.js';
import { getLocalVaultFile, getLocalVaultPath, writeVaultAtomic } from './vault-file.js';

export async function resolveGlobalMasterKey(providedPassword?: string, env?: string): Promise<Uint8Array> {
// 1. Check for Active Session (Agent Mode / Recursive call)
Expand Down Expand Up @@ -103,7 +103,7 @@ export async function createLocalVault(plainTextPayload: string, providedPasswor
const payload = await generateLocalVault(plainTextPayload, gmk);

const vaultPath = getLocalVaultPath(env);
fs.writeFileSync(vaultPath, JSON.stringify(payload, null, 2), 'utf-8');
writeVaultAtomic(vaultPath, payload);

_sodium.memzero(gmk);
p.outro(Flexoki.green(`✔ Vault successfully initialized at ${getLocalVaultFile(env)}`));
Expand Down
25 changes: 25 additions & 0 deletions src/core/vault-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'path';
import fs from 'fs';

export function getLocalVaultFile(env?: string): string {
return `.env${env ? `.${env}` : ''}.vault`;
Expand All @@ -7,3 +8,27 @@ export function getLocalVaultFile(env?: string): string {
export function getLocalVaultPath(env?: string): string {
return path.resolve(process.cwd(), getLocalVaultFile(env));
}

/**
* Atomically writes a payload to the vault path.
* 1. Creates a `.bak` backup of the existing vault if it exists.
* 2. Writes the new payload to a randomized temporary file in the same directory.
* 3. Atomically renames the temporary file to the target vault path to prevent corruption.
*/
export function writeVaultAtomic(vaultPath: string, payload: any): void {
const dir = path.dirname(vaultPath);
fs.mkdirSync(dir, { recursive: true });

// 1. Create backup of current vault if it exists
if (fs.existsSync(vaultPath)) {
const backupPath = `${vaultPath}.bak`;
fs.copyFileSync(vaultPath, backupPath);
}

// 2. Write to randomized temporary file
const tempPath = `${vaultPath}.${Math.random().toString(36).slice(2)}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), 'utf-8');

// 3. Atomic rename to target vault path
fs.renameSync(tempPath, vaultPath);
}
5 changes: 2 additions & 3 deletions src/features/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import dotenv from 'dotenv';
import { resolveGlobalMasterKey } from '../../core/run.js';
import { decryptLocalVault, generateLocalVault, LocalVaultPayload } from '../../core/envelope.js';
import { getGlobalVaultPath } from '../../core/identity.js';
import { getLocalVaultPath, getLocalVaultFile } from '../../core/vault-file.js';
import { getLocalVaultPath, getLocalVaultFile, writeVaultAtomic } from '../../core/vault-file.js';
import { log, Flexoki } from '../tui/components/theme.js';
import { promptForValue } from './tui.js';
import _sodium from 'libsodium-wrappers';
Expand Down Expand Up @@ -56,8 +56,7 @@ export async function addCommand(key: string, valueArg: string | undefined, opti
const newPayload = await generateLocalVault(plainTextPayload, gmk);
_sodium.memzero(gmk);

fs.mkdirSync(path.dirname(vaultPath), { recursive: true });
fs.writeFileSync(vaultPath, JSON.stringify(newPayload, null, 2), 'utf-8');
writeVaultAtomic(vaultPath, newPayload);

const target = isGlobal ? 'global vault' : getLocalVaultFile(options.env);
log.success(`Successfully set ${Flexoki.blue(key)} in ${target}.`);
Expand Down
2 changes: 1 addition & 1 deletion src/features/ingest/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function ingestCommand(filepath: string, options: { dryRun?: boolea
const nonceBytes = sodium.from_hex(payload.nonce);
const cipherBytes = sodium.from_hex(payload.ciphertext);

const key = await deriveKey(otp, saltBytes);
const key = await deriveKey(otp, saltBytes, payload.opslimit, payload.memlimit);
plainTextPayload = await decryptPayload(cipherBytes, nonceBytes, key);

sodium.memzero(key);
Expand Down
4 changes: 2 additions & 2 deletions src/features/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateLocalVault } from '../../core/envelope.js';
import { getGlobalVaultPath } from '../../core/identity.js';
import { log, Flexoki } from '../tui/components/theme.js';
import * as p from '@clack/prompts';
import { writeVaultAtomic } from '../../core/vault-file.js';

/**
* The business logic for the 'vault init' command.
Expand Down Expand Up @@ -44,8 +45,7 @@ export async function initCommand(options: { file?: string; global?: boolean; en
const payload = await generateLocalVault(plainTextPayload, gmk);
const globalVaultPath = getGlobalVaultPath(options.env);

fs.mkdirSync(path.dirname(globalVaultPath), { recursive: true });
fs.writeFileSync(globalVaultPath, JSON.stringify(payload, null, 2), 'utf-8');
writeVaultAtomic(globalVaultPath, payload);

_sodium.memzero(gmk);
p.outro(Flexoki.green(`✔ Global vault successfully updated at ${globalVaultPath}`));
Expand Down
94 changes: 68 additions & 26 deletions src/features/oidc/providers/aws.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,83 @@
import { STSClient, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
import { KMSClient, DecryptCommand } from '@aws-sdk/client-kms';
import { signRequest } from './sigv4.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Extract a value from STS XML response using a simple regex. */
function extractXmlTag(xml: string, tag: string): string {
const re = new RegExp(`<${tag}>([^<]*)</${tag}>`, 'i');
const m = xml.match(re);
if (!m) throw new Error(`Failed to parse ${tag} from STS response`);
return m[1];
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

export async function decryptWithAwsKms(jwt: string, roleArn: string, ciphertextBase64: string): Promise<Uint8Array> {
// AssumeRoleWithWebIdentity does not require AWS credentials — the JWT is the credential.
// Use empty credentials to prevent the SDK from failing on credential chain resolution.
const sts = new STSClient({
credentials: async () => ({ accessKeyId: '', secretAccessKey: '' })
});

const stsResponse = await sts.send(new AssumeRoleWithWebIdentityCommand({
// Step 1: Call STS AssumeRoleWithWebIdentity (unsigned REST API)
const stsBody = new URLSearchParams({
Action: 'AssumeRoleWithWebIdentity',
RoleArn: roleArn,
RoleSessionName: 'vault-ci-session',
WebIdentityToken: jwt
}));
WebIdentityToken: jwt,
Version: '2011-06-15'
});

const stsResponse = await fetch('https://sts.amazonaws.com/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: stsBody.toString()
});

if (!stsResponse.ok) {
throw new Error(`STS AssumeRoleWithWebIdentity failed: ${stsResponse.status} ${stsResponse.statusText}`);
}

const stsXml = await stsResponse.text();
const accessKeyId = extractXmlTag(stsXml, 'AccessKeyId');
const secretAccessKey = extractXmlTag(stsXml, 'SecretAccessKey');
const sessionToken = extractXmlTag(stsXml, 'SessionToken');

const credentials = stsResponse.Credentials;
if (!credentials || !credentials.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) {
if (!accessKeyId || !secretAccessKey || !sessionToken) {
throw new Error('Failed to assume role with web identity: missing credentials or SessionToken.');
}

const kms = new KMSClient({
credentials: {
accessKeyId: credentials.AccessKeyId,
secretAccessKey: credentials.SecretAccessKey,
sessionToken: credentials.SessionToken
}
// Step 2: Call KMS Decrypt with SigV4 signing
const region = process.env.AWS_REGION || 'us-east-1';
const kmsHost = `kms.${region}.amazonaws.com`;
const kmsBody = JSON.stringify({ CiphertextBlob: ciphertextBase64 });

const sig = signRequest(
'POST', kmsHost, '/', region, 'kms', 'TrentService.Decrypt', kmsBody,
accessKeyId, secretAccessKey, sessionToken
);

const kmsResponse = await fetch(`https://${kmsHost}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-amz-json-1.1',
'X-Amz-Target': 'TrentService.Decrypt',
'X-Amz-Date': sig.amzDate,
'Authorization': sig.authorization,
...(sig.securityToken ? { 'X-Amz-Security-Token': sig.securityToken } : {})
},
body: kmsBody
});

const ciphertextBlob = Uint8Array.from(atob(ciphertextBase64), c => c.charCodeAt(0));

const kmsResponse = await kms.send(new DecryptCommand({
CiphertextBlob: ciphertextBlob
}));
if (!kmsResponse.ok) {
throw new Error(`KMS Decrypt failed: ${kmsResponse.status} ${kmsResponse.statusText}`);
}

if (!kmsResponse.Plaintext) {
const kmsData = await kmsResponse.json() as { Plaintext?: string };
if (!kmsData.Plaintext) {
throw new Error('KMS decryption returned empty plaintext.');
}

return kmsResponse.Plaintext;
return Uint8Array.from(atob(kmsData.Plaintext), c => c.charCodeAt(0));
}
67 changes: 67 additions & 0 deletions src/features/oidc/providers/sigv4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createHmac, createHash } from 'crypto';

export function sha256(data: string | Uint8Array): string {
return createHash('sha256').update(data).digest('hex');
}

function hmacSha256(key: string | Uint8Array, data: string): Buffer {
return createHmac('sha256', key).update(data).digest();
}

export interface SigV4Headers {
authorization: string;
amzDate: string;
securityToken?: string;
}

export function signRequest(
method: string,
host: string,
path: string,
region: string,
service: string,
target: string,
body: string,
accessKeyId: string,
secretAccessKey: string,
sessionToken?: string
): SigV4Headers {
const now = new Date();
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
const dateStamp = amzDate.substring(0, 8);

const headers: Record<string, string> = {
'content-type': 'application/x-amz-json-1.1',
'host': host,
'x-amz-date': amzDate,
'x-amz-target': target
};
if (sessionToken) {
headers['x-amz-security-token'] = sessionToken;
}

const signedHeaders = Object.keys(headers).sort().join(';');
const canonicalHeaders = Object.keys(headers).sort()
.map(k => `${k}:${headers[k]}`).join('\n') + '\n';
const payloadHash = sha256(body);

const canonicalRequest = [
method, path || '/', '', canonicalHeaders, signedHeaders, payloadHash
].join('\n');

const algorithm = 'AWS4-HMAC-SHA256';
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = [
algorithm, amzDate, credentialScope, sha256(canonicalRequest)
].join('\n');

const kDate = hmacSha256(`AWS4${secretAccessKey}`, dateStamp);
const kRegion = hmacSha256(kDate, region);
const kService = hmacSha256(kRegion, service);
const kSigning = hmacSha256(kService, 'aws4_request');
const signature = hmacSha256(kSigning, stringToSign).toString('hex');

const authorization = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;

return { authorization, amzDate, securityToken: sessionToken };
}
8 changes: 7 additions & 1 deletion src/features/share/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ export async function shareCommand(options: { env?: string } = {}) {

try {
const otp = await promptForOtp();
const sharedPayload = await encryptPayload(plainTextPayload, otp);
await _sodium.ready;
const sharedPayload = await encryptPayload(
plainTextPayload,
otp,
_sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
_sodium.crypto_pwhash_MEMLIMIT_SENSITIVE
);

const sharedPath = path.resolve(process.cwd(), SHARED_FILE);
fs.writeFileSync(sharedPath, JSON.stringify(sharedPayload, null, 2), 'utf-8');
Expand Down
Loading
Loading