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 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@
"editor.formatOnPaste": true,
"editor.formatOnSaveMode": "file",
"githubIssues.issueBranchTitle": "${issueNumber}-${sanitizedLowercaseIssueTitle}",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
}
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 36 additions & 33 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,50 +1,20 @@
{
"name": "@loggipop/lpop",
"version": "0.3.1",
"description": "A CLI tool for managing environment variables in the system keychain",
"type": "module",
"bin": {
"lpop": "./bin/lpop"
},
"scripts": {
"build:binaries": "./scripts/build-binary.sh",
"build:sign-notarize": "./scripts/build-sign-notarize.sh",
"prepare-packages": "node scripts/prepare-packages.js",
"dev": "bun run src/index.ts",
"clean": "rm -rf dist/ lpop lpop-*",
"lint": "biome check",
"lint:fix": "biome check --write",
"prepack": "bun run build:binaries && bun run prepare-packages",
"postinstall": "node scripts/postinstall.js",
"test": "vitest --run",
"test:watch": "vitest --watch",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"keywords": [
"cli",
"environment",
"variables",
"keychain",
"git"
],
"author": "Loggipop",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/loggipop/lpop.git"
},
"bugs": {
"url": "https://github.com/loggipop/lpop/issues"
},
"homepage": "https://github.com/loggipop/lpop#readme",
"devDependencies": {
"@biomejs/biome": "2.1.4",
"@dajiaji/mlkem": "npm:@jsr/dajiaji__mlkem",
"@napi-rs/keyring": "^1.1.8",
"@types/bun": "latest",
"@types/node": "^24.2.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"bs58": "^6.0.0",
"chalk": "^5.4.1",
"commander": "^14.0.0",
"dotenv": "^17.2.0",
Expand All @@ -63,10 +33,43 @@
"@loggipop/lpop-darwin-arm64": "latest",
"@loggipop/lpop-windows-x64": "latest"
},
"bin": {
"lpop": "./bin/lpop"
},
"bugs": {
"url": "https://github.com/loggipop/lpop/issues"
},
"description": "A CLI tool for managing environment variables in the system keychain",
"files": [
"bin/lpop",
"scripts/postinstall.js",
"README.md",
"LICENSE"
]
],
"homepage": "https://github.com/loggipop/lpop#readme",
"keywords": [
"cli",
"environment",
"variables",
"keychain",
"git"
],
"license": "MIT",
"scripts": {
"build:binaries": "./scripts/build-binary.sh",
"build:sign-notarize": "./scripts/build-sign-notarize.sh",
"prepare-packages": "node scripts/prepare-packages.js",
"dev": "bun run src/index.ts",
"clean": "rm -rf dist/ lpop lpop-*",
"lint": "biome check",
"lint:fix": "biome check --write",
"prepack": "bun run build:binaries && bun run prepare-packages",
"postinstall": "node scripts/postinstall.js",
"test": "vitest --run",
"test:watch": "vitest --watch",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"type": "module"

}
229 changes: 229 additions & 0 deletions src/quantum-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { MlKem768 } from '@dajiaji/mlkem';
import bs58 from 'bs58';

interface DeviceKeyPair {
publicKey: string;
privateKey: string;
createdAt: number;
expiresAt: number;
}

interface EncryptedData {
encryptedKey: string;
ciphertext: string;
}

const KEY_EXPIRY_DAYS = 7;
const LPOP_DIR = join(homedir(), '.lpop');
const DEVICE_KEY_FILE = join(LPOP_DIR, 'device-key.json');

/**
* Ensures the .lpop directory exists in the user's home directory
*/
function ensureLpopDirectory(): void {
if (!existsSync(LPOP_DIR)) {
mkdirSync(LPOP_DIR, { recursive: true });
}
}

/**
* Generates a new ML-KEM768 key pair
*/
export const generatePublicPrivateKeyPair = async (): Promise<{
publicKey: string;
privateKey: string;
}> => {
const kem = new MlKem768();
const [publicKey, privateKey] = await kem.generateKeyPair();
const publicKeyBase58 = bs58.encode(publicKey);
const privateKeyBase58 = bs58.encode(privateKey);
return { publicKey: publicKeyBase58, privateKey: privateKeyBase58 };
};

/**
* Stores device key pair locally with expiration timestamp
*/
export const storeDeviceKey = async (keyPair: {
publicKey: string;
privateKey: string;
}): Promise<void> => {
ensureLpopDirectory();

const now = Date.now();
const deviceKey: DeviceKeyPair = {
...keyPair,
createdAt: now,
expiresAt: now + KEY_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
};

writeFileSync(DEVICE_KEY_FILE, JSON.stringify(deviceKey, null, 2), 'utf8');
};

/**
* Retrieves stored device key pair if it exists and hasn't expired
*/
export const getStoredDeviceKey = (): DeviceKeyPair | null => {
if (!existsSync(DEVICE_KEY_FILE)) {
return null;
}

try {
const keyData = JSON.parse(
readFileSync(DEVICE_KEY_FILE, 'utf8'),
) as DeviceKeyPair;

// Check if key has expired
if (Date.now() > keyData.expiresAt) {
// Remove expired key
unlinkSync(DEVICE_KEY_FILE);
return null;
}

return keyData;
} catch {
// If file is corrupted, remove it
unlinkSync(DEVICE_KEY_FILE);
return null;
}
};

/**
* Gets or generates device key pair, automatically handling expiration
*/
export const getOrCreateDeviceKey = async (): Promise<DeviceKeyPair> => {
let deviceKey = getStoredDeviceKey();

if (!deviceKey) {
const keyPair = await generatePublicPrivateKeyPair();
await storeDeviceKey(keyPair);
deviceKey = getStoredDeviceKey();

if (!deviceKey) {
throw new Error('Failed to store or retrieve device key');
}
}

return deviceKey;
};

/**
* Encrypts data using ML-KEM with the recipient's public key
*/
export const encryptForPublicKey = async (
data: string,
publicKeyBase58: string,
): Promise<EncryptedData> => {
const kem = new MlKem768();
const publicKey = bs58.decode(publicKeyBase58);

// Generate shared secret using KEM
const [encryptedKey, sharedSecret] = await kem.encap(publicKey);

// Use AES-256-GCM with the shared secret as key
// Derive a 256-bit key from the shared secret
const aesKey = sharedSecret.slice(0, 32);

// Generate a random 12-byte IV for GCM
const iv = randomBytes(12);

// Create cipher with AES-256-GCM
const cipher = createCipheriv('aes-256-gcm', aesKey, iv);

// Encrypt the data
const encrypted = Buffer.concat([
cipher.update(data, 'utf8'),
cipher.final(),
]);

// Get the authentication tag
const authTag = cipher.getAuthTag();

// Combine IV, authTag, and ciphertext
const combined = Buffer.concat([iv, authTag, encrypted]);

return {
encryptedKey: bs58.encode(encryptedKey),
ciphertext: bs58.encode(combined),
};
};

/**
* Decrypts data using ML-KEM with the local private key
*/
export const decryptWithPrivateKey = async (
encryptedData: EncryptedData,
privateKeyBase58: string,
): Promise<string> => {
const kem = new MlKem768();
const privateKey = bs58.decode(privateKeyBase58);
const encryptedKey = bs58.decode(encryptedData.encryptedKey);

// Recover shared secret using KEM
const sharedSecret = await kem.decap(encryptedKey, privateKey);

// Derive the same AES key from the shared secret
const aesKey = sharedSecret.slice(0, 32);

// Decode and extract components
const combined = bs58.decode(encryptedData.ciphertext);

// Extract IV (first 12 bytes), authTag (next 16 bytes), and ciphertext (rest)
const iv = combined.slice(0, 12);
const authTag = combined.slice(12, 28);
const ciphertext = combined.slice(28);

// Create decipher with AES-256-GCM
const decipher = createDecipheriv('aes-256-gcm', aesKey, iv);
decipher.setAuthTag(authTag);

// Decrypt the data
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);

return decrypted.toString('utf8');
};

/**
* Removes expired or invalid device keys
*/
export const cleanupExpiredKeys = (): boolean => {
const deviceKey = getStoredDeviceKey();
return deviceKey === null; // Returns true if key was removed/expired
};

/**
* Gets device key status information
*/
export const getDeviceKeyStatus = (): {
exists: boolean;
expiresAt?: number;
daysUntilExpiry?: number;
} => {
const deviceKey = getStoredDeviceKey();

if (!deviceKey) {
return { exists: false };
}

const daysUntilExpiry = Math.ceil(
(deviceKey.expiresAt - Date.now()) / (24 * 60 * 60 * 1000),
);

return {
exists: true,
expiresAt: deviceKey.expiresAt,
daysUntilExpiry,
};
};
Loading
Loading