feat(security): integrate @push.rocks/smartsecret for keychain-based token storage
Connection tokens are now stored in OS keychain (or encrypted file fallback) instead of plaintext JSON. Existing plaintext tokens auto-migrate on first load.
This commit is contained in:
@@ -19,7 +19,8 @@
|
|||||||
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3",
|
"@apiclient.xyz/gitea": "npm:@apiclient.xyz/gitea@^1.0.3",
|
||||||
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
|
"@apiclient.xyz/gitlab": "npm:@apiclient.xyz/gitlab@^2.0.3",
|
||||||
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
|
"@push.rocks/smartmongo": "npm:@push.rocks/smartmongo@^5.1.0",
|
||||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15"
|
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.0.15",
|
||||||
|
"@push.rocks/smartsecret": "npm:@push.rocks/smartsecret@^1.0.1"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BaseProvider, GiteaProvider, GitLabProvider } from '../ts/providers/ind
|
|||||||
import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
|
import { ConnectionManager } from '../ts/classes/connectionmanager.ts';
|
||||||
import { GitopsApp } from '../ts/classes/gitopsapp.ts';
|
import { GitopsApp } from '../ts/classes/gitopsapp.ts';
|
||||||
import { StorageManager } from '../ts/storage/index.ts';
|
import { StorageManager } from '../ts/storage/index.ts';
|
||||||
|
import * as smartsecret from '@push.rocks/smartsecret';
|
||||||
|
|
||||||
Deno.test('GiteaProvider instantiates correctly', () => {
|
Deno.test('GiteaProvider instantiates correctly', () => {
|
||||||
const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token');
|
const provider = new GiteaProvider('test-id', 'https://gitea.example.com', 'test-token');
|
||||||
@@ -20,7 +21,8 @@ Deno.test('GitLabProvider instantiates correctly', () => {
|
|||||||
|
|
||||||
Deno.test('ConnectionManager instantiates correctly', () => {
|
Deno.test('ConnectionManager instantiates correctly', () => {
|
||||||
const storage = new StorageManager({ backend: 'memory' });
|
const storage = new StorageManager({ backend: 'memory' });
|
||||||
const manager = new ConnectionManager(storage);
|
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
|
||||||
|
const manager = new ConnectionManager(storage, secret);
|
||||||
assertExists(manager);
|
assertExists(manager);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ Deno.test('GitopsApp instantiates correctly', () => {
|
|||||||
const app = new GitopsApp();
|
const app = new GitopsApp();
|
||||||
assertExists(app);
|
assertExists(app);
|
||||||
assertExists(app.storageManager);
|
assertExists(app.storageManager);
|
||||||
|
assertExists(app.smartSecret);
|
||||||
assertExists(app.connectionManager);
|
assertExists(app.connectionManager);
|
||||||
assertExists(app.opsServer);
|
assertExists(app.opsServer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
import { assertEquals, assertExists } from 'https://deno.land/std@0.208.0/assert/mod.ts';
|
||||||
import { StorageManager } from '../ts/storage/index.ts';
|
import { StorageManager } from '../ts/storage/index.ts';
|
||||||
|
import * as smartsecret from '@push.rocks/smartsecret';
|
||||||
|
|
||||||
Deno.test('StorageManager memory: set and get', async () => {
|
Deno.test('StorageManager memory: set and get', async () => {
|
||||||
const sm = new StorageManager({ backend: 'memory' });
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
@@ -114,7 +115,8 @@ Deno.test('StorageManager filesystem: list keys', async () => {
|
|||||||
Deno.test('ConnectionManager with StorageManager: create and load', async () => {
|
Deno.test('ConnectionManager with StorageManager: create and load', async () => {
|
||||||
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
|
const { ConnectionManager } = await import('../ts/classes/connectionmanager.ts');
|
||||||
const sm = new StorageManager({ backend: 'memory' });
|
const sm = new StorageManager({ backend: 'memory' });
|
||||||
const cm = new ConnectionManager(sm);
|
const secret = new smartsecret.SmartSecret({ service: 'gitops-test' });
|
||||||
|
const cm = new ConnectionManager(sm, secret);
|
||||||
await cm.init();
|
await cm.init();
|
||||||
|
|
||||||
// Create a connection
|
// Create a connection
|
||||||
@@ -129,7 +131,7 @@ Deno.test('ConnectionManager with StorageManager: create and load', async () =>
|
|||||||
assertEquals(stored.id, conn.id);
|
assertEquals(stored.id, conn.id);
|
||||||
|
|
||||||
// Create a new ConnectionManager and verify it loads the connection
|
// Create a new ConnectionManager and verify it loads the connection
|
||||||
const cm2 = new ConnectionManager(sm);
|
const cm2 = new ConnectionManager(sm, secret);
|
||||||
await cm2.init();
|
await cm2.init();
|
||||||
const conns = cm2.getConnections();
|
const conns = cm2.getConnections();
|
||||||
assertEquals(conns.length, 1);
|
assertEquals(conns.length, 1);
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ import type { StorageManager } from '../storage/index.ts';
|
|||||||
|
|
||||||
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
|
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
|
||||||
const CONNECTIONS_PREFIX = '/connections/';
|
const CONNECTIONS_PREFIX = '/connections/';
|
||||||
|
const KEYCHAIN_PREFIX = 'keychain:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages provider connections — persists each connection as an
|
* Manages provider connections — persists each connection as an
|
||||||
* individual JSON file via StorageManager.
|
* individual JSON file via StorageManager. Tokens are stored in
|
||||||
|
* the OS keychain (or encrypted file fallback) via SmartSecret.
|
||||||
*/
|
*/
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
private connections: interfaces.data.IProviderConnection[] = [];
|
private connections: interfaces.data.IProviderConnection[] = [];
|
||||||
private storageManager: StorageManager;
|
private storageManager: StorageManager;
|
||||||
|
private smartSecret: plugins.smartsecret.SmartSecret;
|
||||||
|
|
||||||
constructor(storageManager: StorageManager) {
|
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
|
this.smartSecret = smartSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
@@ -51,6 +55,18 @@ export class ConnectionManager {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
|
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
|
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
|
||||||
|
// Token is in keychain — retrieve it
|
||||||
|
const realToken = await this.smartSecret.getSecret(conn.id);
|
||||||
|
if (realToken) {
|
||||||
|
conn.token = realToken;
|
||||||
|
} else {
|
||||||
|
logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`);
|
||||||
|
}
|
||||||
|
} else if (conn.token && conn.token !== '***') {
|
||||||
|
// Plaintext token found — auto-migrate to keychain
|
||||||
|
await this.migrateTokenToKeychain(conn);
|
||||||
|
}
|
||||||
this.connections.push(conn);
|
this.connections.push(conn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,11 +77,33 @@ export class ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates a plaintext token to keychain storage.
|
||||||
|
*/
|
||||||
|
private async migrateTokenToKeychain(
|
||||||
|
conn: interfaces.data.IProviderConnection,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
||||||
|
// Save sentinel to JSON file
|
||||||
|
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
|
||||||
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
||||||
|
logger.info(`Migrated token for connection "${conn.name}" to keychain`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to migrate token for ${conn.id} to keychain: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
|
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
|
||||||
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn);
|
// Store real token in keychain
|
||||||
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
||||||
|
// Save JSON with sentinel value
|
||||||
|
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
|
||||||
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeConnection(id: string): Promise<void> {
|
private async removeConnection(id: string): Promise<void> {
|
||||||
|
await this.smartSecret.deleteSecret(id);
|
||||||
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
|
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { ConnectionManager } from './connectionmanager.ts';
|
import { ConnectionManager } from './connectionmanager.ts';
|
||||||
import { OpsServer } from '../opsserver/index.ts';
|
import { OpsServer } from '../opsserver/index.ts';
|
||||||
@@ -10,6 +11,7 @@ import { resolvePaths } from '../paths.ts';
|
|||||||
*/
|
*/
|
||||||
export class GitopsApp {
|
export class GitopsApp {
|
||||||
public storageManager: StorageManager;
|
public storageManager: StorageManager;
|
||||||
|
public smartSecret: plugins.smartsecret.SmartSecret;
|
||||||
public connectionManager: ConnectionManager;
|
public connectionManager: ConnectionManager;
|
||||||
public opsServer: OpsServer;
|
public opsServer: OpsServer;
|
||||||
public cacheDb: CacheDb;
|
public cacheDb: CacheDb;
|
||||||
@@ -21,7 +23,8 @@ export class GitopsApp {
|
|||||||
backend: 'filesystem',
|
backend: 'filesystem',
|
||||||
fsPath: paths.defaultStoragePath,
|
fsPath: paths.defaultStoragePath,
|
||||||
});
|
});
|
||||||
this.connectionManager = new ConnectionManager(this.storageManager);
|
this.smartSecret = new plugins.smartsecret.SmartSecret({ service: 'gitops' });
|
||||||
|
this.connectionManager = new ConnectionManager(this.storageManager, this.smartSecret);
|
||||||
|
|
||||||
this.cacheDb = CacheDb.getInstance({
|
this.cacheDb = CacheDb.getInstance({
|
||||||
storagePath: paths.defaultTsmDbPath,
|
storagePath: paths.defaultTsmDbPath,
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ export { giteaClient, gitlabClient };
|
|||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
export { smartmongo, smartdata };
|
export { smartmongo, smartdata };
|
||||||
|
|
||||||
|
// Secrets
|
||||||
|
import * as smartsecret from '@push.rocks/smartsecret';
|
||||||
|
export { smartsecret };
|
||||||
|
|||||||
Reference in New Issue
Block a user