feat(secrets): add a 0600 file secret backend so production runs on Linux
The production composition hardwired the macOS Keychain (/usr/bin/security), which made Linux deployment impossible. Now: - SecretStore gains a stable provider identity persisted in secret_references.provider; services stop hardcoding 'macos-keychain' - shared reference codec (secret-reference.ts) understands both keychain:// and secret-file:// references - FileSecretStore: single 0600 JSON map under the data root, atomic temp+rename writes, serialized in-process, same validation envelope - production composition picks the backend via MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store elsewhere) and readiness probes the matching backend
This commit is contained in:
@@ -17,6 +17,7 @@ describe('ScheduledOperationDispatcher', () => {
|
|||||||
operations: { prepare: vi.fn(), execute: vi.fn() },
|
operations: { prepare: vi.fn(), execute: vi.fn() },
|
||||||
messages: { send },
|
messages: { send },
|
||||||
store: {
|
store: {
|
||||||
|
provider: 'macos-keychain',
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
get: vi.fn(async () =>
|
get: vi.fn(async () =>
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -90,7 +91,7 @@ describe('ScheduledOperationDispatcher', () => {
|
|||||||
execute,
|
execute,
|
||||||
},
|
},
|
||||||
messages: { send: vi.fn() },
|
messages: { send: vi.fn() },
|
||||||
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||||
repository: new ScheduledTaskRepository(db),
|
repository: new ScheduledTaskRepository(db),
|
||||||
});
|
});
|
||||||
const result = await dispatcher.dispatch(
|
const result = await dispatcher.dispatch(
|
||||||
@@ -179,6 +180,7 @@ describe('ScheduledOperationDispatcher', () => {
|
|||||||
operations: { prepare: vi.fn(), execute: vi.fn() },
|
operations: { prepare: vi.fn(), execute: vi.fn() },
|
||||||
messages: { send },
|
messages: { send },
|
||||||
store: {
|
store: {
|
||||||
|
provider: 'macos-keychain',
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
get: vi.fn(async () =>
|
get: vi.fn(async () =>
|
||||||
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
|
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
|
||||||
@@ -216,7 +218,7 @@ describe('ScheduledOperationDispatcher', () => {
|
|||||||
execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }),
|
execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }),
|
||||||
},
|
},
|
||||||
messages: { send: vi.fn() },
|
messages: { send: vi.fn() },
|
||||||
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||||
repository: new ScheduledTaskRepository(db),
|
repository: new ScheduledTaskRepository(db),
|
||||||
});
|
});
|
||||||
const result = await dispatcher.dispatch(
|
const result = await dispatcher.dispatch(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js';
|
|||||||
import { ScheduledTaskService } from './scheduled-task-service.js';
|
import { ScheduledTaskService } from './scheduled-task-service.js';
|
||||||
|
|
||||||
class MemorySecrets implements SecretStore {
|
class MemorySecrets implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
readonly values = new Map<string, string>();
|
readonly values = new Map<string, string>();
|
||||||
readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
|
readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
|
||||||
blockRotations = false;
|
blockRotations = false;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ConnectionProbe } from './connection-probe.js';
|
|||||||
import { ConnectionLogService } from '../system/connection-log-service.js';
|
import { ConnectionLogService } from '../system/connection-log-service.js';
|
||||||
|
|
||||||
class Store implements SecretStore {
|
class Store implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||||
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
|
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('InstanceCredentialResolver', () => {
|
|||||||
it('resolves only the canonical password reference for the requested existing instance', async () => {
|
it('resolves only the canonical password reference for the requested existing instance', async () => {
|
||||||
let asked: string | undefined;
|
let asked: string | undefined;
|
||||||
const { db, resolver } = fixture({
|
const { db, resolver } = fixture({
|
||||||
|
provider: 'macos-keychain',
|
||||||
set: async () => '',
|
set: async () => '',
|
||||||
get: async (reference) => {
|
get: async (reference) => {
|
||||||
asked = reference;
|
asked = reference;
|
||||||
@@ -44,6 +45,7 @@ describe('InstanceCredentialResolver', () => {
|
|||||||
it('does not call SecretStore when no saved credential exists', async () => {
|
it('does not call SecretStore when no saved credential exists', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const { resolver } = fixture({
|
const { resolver } = fixture({
|
||||||
|
provider: 'macos-keychain',
|
||||||
set: async () => '',
|
set: async () => '',
|
||||||
get: async () => {
|
get: async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
@@ -57,6 +59,7 @@ describe('InstanceCredentialResolver', () => {
|
|||||||
it('rejects a reference bound to another instance before SecretStore access', async () => {
|
it('rejects a reference bound to another instance before SecretStore access', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const { db, resolver } = fixture({
|
const { db, resolver } = fixture({
|
||||||
|
provider: 'macos-keychain',
|
||||||
set: async () => '',
|
set: async () => '',
|
||||||
get: async () => {
|
get: async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
||||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||||
|
|
||||||
const PURPOSE = 'instance-password';
|
const PURPOSE = 'instance-password';
|
||||||
const PROVIDER = 'macos-keychain';
|
|
||||||
export class CredentialResolverError extends Error {
|
export class CredentialResolverError extends Error {
|
||||||
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
|
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
|
||||||
super(code);
|
super(code);
|
||||||
@@ -24,10 +23,12 @@ export class InstanceCredentialResolver {
|
|||||||
.prepare(
|
.prepare(
|
||||||
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
|
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
|
||||||
)
|
)
|
||||||
.get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined;
|
.get(instanceId, PURPOSE, this.options.store.provider) as
|
||||||
|
| { external_reference: string }
|
||||||
|
| undefined;
|
||||||
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
|
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
|
||||||
try {
|
try {
|
||||||
const parsed = parseKeychainReference(row.external_reference);
|
const parsed = parseSecretReference(row.external_reference);
|
||||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
|
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
|
||||||
throw new Error('invalid binding');
|
throw new Error('invalid binding');
|
||||||
const secret = await this.options.store.get(row.external_reference);
|
const secret = await this.options.store.get(row.external_reference);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Instance } from '@multi-simadmin/contracts';
|
|||||||
import { InstanceService, InstanceServiceError } from './instance-service.js';
|
import { InstanceService, InstanceServiceError } from './instance-service.js';
|
||||||
|
|
||||||
class MemorySecrets implements SecretStore {
|
class MemorySecrets implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
readonly values = new Map<string, string>();
|
readonly values = new Map<string, string>();
|
||||||
readonly deletes: string[] = [];
|
readonly deletes: string[] = [];
|
||||||
readonly sets: Array<{
|
readonly sets: Array<{
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import type {
|
|||||||
SnapshotFreshness,
|
SnapshotFreshness,
|
||||||
} from '@multi-simadmin/contracts';
|
} from '@multi-simadmin/contracts';
|
||||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
||||||
|
|
||||||
const PURPOSE = 'instance-password';
|
const PURPOSE = 'instance-password';
|
||||||
const PROVIDER = 'macos-keychain';
|
|
||||||
const MAX_TAGS = 50;
|
const MAX_TAGS = 50;
|
||||||
const MAX_TAG_LENGTH = 100;
|
const MAX_TAG_LENGTH = 100;
|
||||||
|
|
||||||
@@ -408,7 +407,7 @@ export class InstanceService {
|
|||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||||
)
|
)
|
||||||
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
|
.run(id, instanceId, PURPOSE, this.store.provider, external, now, now);
|
||||||
}
|
}
|
||||||
private async storeSecret(
|
private async storeSecret(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
@@ -422,7 +421,7 @@ export class InstanceService {
|
|||||||
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = parseKeychainReference(external);
|
const parsed = parseSecretReference(external);
|
||||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
|
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
|
||||||
throw new Error('secret store returned a reference with an invalid binding');
|
throw new Error('secret store returned a reference with an invalid binding');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -504,7 +503,7 @@ export class InstanceService {
|
|||||||
AND secret_cleanup_tasks.purpose=excluded.purpose
|
AND secret_cleanup_tasks.purpose=excluded.purpose
|
||||||
AND secret_cleanup_tasks.provider=excluded.provider`,
|
AND secret_cleanup_tasks.provider=excluded.provider`,
|
||||||
)
|
)
|
||||||
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
|
.run(reference, instanceId, PURPOSE, this.store.provider, now, now);
|
||||||
if (queued.changes !== 1)
|
if (queued.changes !== 1)
|
||||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
|
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
|
||||||
}
|
}
|
||||||
@@ -529,13 +528,13 @@ export class InstanceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
private validateCleanupEntry(entry: CleanupEntry): void {
|
private validateCleanupEntry(entry: CleanupEntry): void {
|
||||||
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
|
if (entry.purpose !== PURPOSE || entry.provider !== this.store.provider)
|
||||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
||||||
this.validateCleanupReference(entry.instanceId, entry.reference);
|
this.validateCleanupReference(entry.instanceId, entry.reference);
|
||||||
}
|
}
|
||||||
private validateCleanupReference(instanceId: string, reference: string): void {
|
private validateCleanupReference(instanceId: string, reference: string): void {
|
||||||
try {
|
try {
|
||||||
const parsed = parseKeychainReference(reference);
|
const parsed = parseSecretReference(reference);
|
||||||
if (
|
if (
|
||||||
parsed.instanceId !== instanceId ||
|
parsed.instanceId !== instanceId ||
|
||||||
parsed.purpose !== PURPOSE ||
|
parsed.purpose !== PURPOSE ||
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function pendingDatabase(): Database.Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeStore implements SecretStore {
|
class FakeStore implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
readonly sets: { key: SecretKey; value: string }[] = [];
|
readonly sets: { key: SecretKey; value: string }[] = [];
|
||||||
readonly deletes: string[] = [];
|
readonly deletes: string[] = [];
|
||||||
setError?: Error;
|
setError?: Error;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getManagedDatabaseIdentity } from '../../infrastructure/database/databa
|
|||||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||||
|
|
||||||
const PURPOSE = 'instance-password';
|
const PURPOSE = 'instance-password';
|
||||||
const PROVIDER = 'macos-keychain';
|
|
||||||
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
|
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
|
||||||
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
|
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@ async function activatePendingSecretExclusive({
|
|||||||
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
|
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||||
).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now);
|
).run(id, instanceId, PURPOSE, store.provider, externalReference, now, now);
|
||||||
const updated = db
|
const updated = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
|
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from './central-notification-service.js';
|
} from './central-notification-service.js';
|
||||||
|
|
||||||
class MemorySecrets implements SecretStore {
|
class MemorySecrets implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
readonly values = new Map<string, string>();
|
readonly values = new Map<string, string>();
|
||||||
|
|
||||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from './delete-instance-operation.js';
|
} from './delete-instance-operation.js';
|
||||||
|
|
||||||
class Store implements SecretStore {
|
class Store implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
async set() {
|
async set() {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ it('registers the read-only operations catalog without touching upstream', async
|
|||||||
let upstreamCalls = 0;
|
let upstreamCalls = 0;
|
||||||
const app = buildControlPlaneApp({
|
const app = buildControlPlaneApp({
|
||||||
db,
|
db,
|
||||||
store: { set: async () => '', get: async () => undefined, delete: async () => false },
|
store: {
|
||||||
|
provider: 'macos-keychain',
|
||||||
|
set: async () => '',
|
||||||
|
get: async () => undefined,
|
||||||
|
delete: async () => false,
|
||||||
|
},
|
||||||
upstream: {
|
upstream: {
|
||||||
get: async () => {
|
get: async () => {
|
||||||
upstreamCalls += 1;
|
upstreamCalls += 1;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ afterEach(async () => {
|
|||||||
for (const db of dbs.splice(0)) db.close();
|
for (const db of dbs.splice(0)) db.close();
|
||||||
});
|
});
|
||||||
class Store implements SecretStore {
|
class Store implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
private readonly values = new Map<string, string>();
|
private readonly values = new Map<string, string>();
|
||||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||||
const account = Buffer.from(
|
const account = Buffer.from(
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ describe('verified backup, restore, and rollback foundation', () => {
|
|||||||
|
|
||||||
// Activation renames a WAL file that a live connection still holds open;
|
// Activation renames a WAL file that a live connection still holds open;
|
||||||
// POSIX permits that, Windows file locking does not.
|
// POSIX permits that, Windows file locking does not.
|
||||||
it('verifies a separate candidate and digest-binds activation', { skip: process.platform === 'win32' && 'relies on POSIX rename-over-open-file semantics' }, async () => {
|
const itPosix = process.platform === 'win32' ? it.skip : it;
|
||||||
|
itPosix('verifies a separate candidate and digest-binds activation', async () => {
|
||||||
const { livePath, backupPath, database } = await fixture();
|
const { livePath, backupPath, database } = await fixture();
|
||||||
const snapshot = await backupDatabase(database, backupPath);
|
const snapshot = await backupDatabase(database, backupPath);
|
||||||
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
|
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
|
||||||
@@ -121,7 +122,7 @@ describe('verified backup, restore, and rollback foundation', () => {
|
|||||||
expect(await readFile(livePath)).toEqual(before);
|
expect(await readFile(livePath)).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects in-place staged-byte mutation even when metadata is preserved', { skip: process.platform === 'win32' && 'uses python3 utime and POSIX rename-over-open-file semantics' }, async () => {
|
itPosix('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
|
||||||
const { livePath, backupPath, database } = await fixture();
|
const { livePath, backupPath, database } = await fixture();
|
||||||
await backupDatabase(database, backupPath);
|
await backupDatabase(database, backupPath);
|
||||||
database.close();
|
database.close();
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||||
|
import { dirname, isAbsolute } from 'node:path';
|
||||||
|
import {
|
||||||
|
accountFor,
|
||||||
|
parseSecretAccount,
|
||||||
|
validateKey,
|
||||||
|
SecretReferenceError,
|
||||||
|
} from './secret-reference.js';
|
||||||
|
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||||
|
|
||||||
|
const PROVIDER = 'secret-file';
|
||||||
|
const SERVICE = 'multi-simadmin';
|
||||||
|
const MAX_SECRET_BYTES = 16_384;
|
||||||
|
const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type FileSecretStoreErrorCode =
|
||||||
|
| 'INVALID_KEY'
|
||||||
|
| 'INVALID_SECRET'
|
||||||
|
| 'INVALID_REFERENCE'
|
||||||
|
| 'INVALID_STORE_PATH'
|
||||||
|
| 'SIZE_LIMIT'
|
||||||
|
| 'PAYLOAD_INVALID';
|
||||||
|
|
||||||
|
export class FileSecretStoreError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: FileSecretStoreErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FileSecretStoreError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(code: FileSecretStoreErrorCode, message: string): never {
|
||||||
|
throw new FileSecretStoreError(code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSIX filesystem-backed secret store for hosts without a system keyring
|
||||||
|
* (e.g. Linux servers). Secrets live in a single 0600 JSON map under the data
|
||||||
|
* root, replaced atomically via temp file + rename, with writes serialized
|
||||||
|
* in-process.
|
||||||
|
*/
|
||||||
|
export class FileSecretStore implements SecretStore {
|
||||||
|
readonly provider = PROVIDER;
|
||||||
|
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(readonly filePath: string) {
|
||||||
|
if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath))
|
||||||
|
invalid('INVALID_STORE_PATH', 'Secret file path must be absolute');
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: SecretKey, value: string): Promise<string> {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length === 0 ||
|
||||||
|
/[\0\r\n]/.test(value) ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES
|
||||||
|
) {
|
||||||
|
invalid('INVALID_SECRET', 'Secret is not valid for file storage');
|
||||||
|
}
|
||||||
|
validateKey(key);
|
||||||
|
const account = accountFor(key);
|
||||||
|
await this.enqueue(() => this.withStore((store) => ({ ...store, [account]: value })));
|
||||||
|
return `secret-file://${SERVICE}/${account}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(reference: string): Promise<string | undefined> {
|
||||||
|
const { account } = this.parse(reference);
|
||||||
|
const store = await this.readStore();
|
||||||
|
return store[account];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(reference: string): Promise<boolean> {
|
||||||
|
const { account } = this.parse(reference);
|
||||||
|
let removed = false;
|
||||||
|
await this.enqueue(() =>
|
||||||
|
this.withStore((store) => {
|
||||||
|
if (!(account in store)) return store;
|
||||||
|
removed = true;
|
||||||
|
const next = { ...store };
|
||||||
|
delete next[account];
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parse(reference: string): { account: string } {
|
||||||
|
if (typeof reference !== 'string' || reference.length > 512)
|
||||||
|
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||||
|
const match = /^secret-file:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||||
|
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||||
|
try {
|
||||||
|
parseSecretAccount(match[1], PROVIDER);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SecretReferenceError)
|
||||||
|
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { account: match[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const run = this.queue.then(task, task);
|
||||||
|
this.queue = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readStore(): Promise<Record<string, string>> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(this.filePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES)
|
||||||
|
invalid('SIZE_LIMIT', 'Secret file exceeds the size limit');
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
|
||||||
|
}
|
||||||
|
if (!isPlainStringMap(parsed)) invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the current map, applies `mutate`, and durably replaces the file. */
|
||||||
|
private async withStore(
|
||||||
|
mutate: (store: Record<string, string>) => Record<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const current = await this.readStore();
|
||||||
|
const next = mutate(current);
|
||||||
|
const directory = dirname(this.filePath);
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
const info = await stat(directory);
|
||||||
|
if (info.isDirectory() && (info.mode & 0o077) !== 0) await chmod(directory, 0o700);
|
||||||
|
}
|
||||||
|
const temporary = `${this.filePath}.${randomUUID()}.tmp`;
|
||||||
|
const handle = await open(temporary, 'wx', 0o600);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||||
|
// Windows FlushFileBuffers is skipped along with the directory fsync below;
|
||||||
|
// durability anchoring is POSIX-only by design.
|
||||||
|
if (process.platform !== 'win32') await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rename(temporary, this.filePath);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
const directoryHandle = await open(directory, 'r');
|
||||||
|
try {
|
||||||
|
await directoryHandle.sync();
|
||||||
|
} finally {
|
||||||
|
await directoryHandle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainStringMap(value: unknown): value is Record<string, string> {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||||
|
return Object.values(value).every((entry) => typeof entry === 'string');
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
accountFor,
|
||||||
|
parseSecretAccount,
|
||||||
|
SecretReferenceError,
|
||||||
|
} from './secret-reference.js';
|
||||||
import type { SecretKey, SecretStore } from './secret-store.js';
|
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||||
|
|
||||||
const SECURITY_PATH = '/usr/bin/security';
|
const SECURITY_PATH = '/usr/bin/security';
|
||||||
const SERVICE = 'multi-simadmin';
|
const SERVICE = 'multi-simadmin';
|
||||||
const TIMEOUT_MS = 5_000;
|
const TIMEOUT_MS = 5_000;
|
||||||
const MAX_STDOUT_BYTES = 65_536;
|
const MAX_STDOUT_BYTES = 65_536;
|
||||||
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
|
||||||
|
|
||||||
export type SecretStoreErrorCode =
|
export type SecretStoreErrorCode =
|
||||||
| 'INVALID_KEY'
|
| 'INVALID_KEY'
|
||||||
@@ -105,43 +109,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne
|
|||||||
throw new SecretStoreError(code, message);
|
throw new SecretStoreError(code, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateKey(key: SecretKey): void {
|
|
||||||
if (
|
|
||||||
typeof key.instanceId !== 'string' ||
|
|
||||||
key.instanceId.length === 0 ||
|
|
||||||
key.instanceId.length > 128 ||
|
|
||||||
!safeComponent.test(key.instanceId)
|
|
||||||
) {
|
|
||||||
invalid('INVALID_KEY', 'Secret key instance id is invalid');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof key.purpose !== 'string' ||
|
|
||||||
key.purpose.length === 0 ||
|
|
||||||
key.purpose.length > 64 ||
|
|
||||||
!safeComponent.test(key.purpose)
|
|
||||||
) {
|
|
||||||
invalid('INVALID_KEY', 'Secret key purpose is invalid');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
key.slot !== undefined &&
|
|
||||||
(typeof key.slot !== 'string' ||
|
|
||||||
key.slot.length === 0 ||
|
|
||||||
key.slot.length > 64 ||
|
|
||||||
!safeComponent.test(key.slot))
|
|
||||||
) {
|
|
||||||
invalid('INVALID_KEY', 'Secret key slot is invalid');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function accountFor(key: SecretKey): string {
|
|
||||||
validateKey(key);
|
|
||||||
const components =
|
|
||||||
key.slot === undefined
|
|
||||||
? [key.instanceId, key.purpose]
|
|
||||||
: [key.instanceId, key.purpose, key.slot];
|
|
||||||
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
function referenceFor(account: string): string {
|
function referenceFor(account: string): string {
|
||||||
return `keychain://${SERVICE}/${account}`;
|
return `keychain://${SERVICE}/${account}`;
|
||||||
}
|
}
|
||||||
@@ -152,28 +119,26 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
|
|||||||
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||||
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||||
const account = match[1];
|
const account = match[1];
|
||||||
let decoded: unknown;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
parsed = parseSecretAccount(account, 'macos-keychain');
|
||||||
} catch {
|
} catch (error) {
|
||||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
if (error instanceof SecretReferenceError)
|
||||||
|
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
|
||||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
|
||||||
const [instanceId, purpose, slot] = decoded;
|
|
||||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
|
||||||
validateKey(key);
|
|
||||||
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
|
||||||
return {
|
return {
|
||||||
service: SERVICE,
|
service: SERVICE,
|
||||||
account,
|
account,
|
||||||
instanceId: key.instanceId,
|
instanceId: parsed.instanceId,
|
||||||
purpose: key.purpose,
|
purpose: parsed.purpose,
|
||||||
...(key.slot === undefined ? {} : { slot: key.slot }),
|
...(parsed.slot === undefined ? {} : { slot: parsed.slot }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MacOSKeychainSecretStore implements SecretStore {
|
export class MacOSKeychainSecretStore implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
|
|
||||||
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
|
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
|
||||||
|
|
||||||
async set(key: SecretKey, value: string): Promise<string> {
|
async set(key: SecretKey, value: string): Promise<string> {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtemp, readFile, readdir, rm, stat, writeFile, chmod } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { FileSecretStore, FileSecretStoreError } from './file-secret-store.js';
|
||||||
|
import { SecretReferenceError } from './secret-reference.js';
|
||||||
|
import {
|
||||||
|
createDefaultSecretStore,
|
||||||
|
defaultSecretFileMetadataCheck,
|
||||||
|
resolveSecretBackend,
|
||||||
|
SecretBackendError,
|
||||||
|
} from './secret-backend.js';
|
||||||
|
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||||
|
|
||||||
|
describe('FileSecretStore', () => {
|
||||||
|
it('round-trips set/get/delete with stable secret-file references', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||||
|
try {
|
||||||
|
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||||
|
const reference = await store.set(
|
||||||
|
{ instanceId: 'device-1', purpose: 'instance-password', slot: 'slot-1' },
|
||||||
|
'pa$$word',
|
||||||
|
);
|
||||||
|
expect(reference).toMatch(/^secret-file:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/);
|
||||||
|
expect(await store.get(reference)).toBe('pa$$word');
|
||||||
|
expect(await store.delete(reference)).toBe(true);
|
||||||
|
expect(await store.get(reference)).toBeUndefined();
|
||||||
|
expect(await store.delete(reference)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, framing, oversize, and malformed keys/values', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||||
|
try {
|
||||||
|
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||||
|
await expect(store.set({ instanceId: 'x', purpose: 'p' }, '')).rejects.toThrow(
|
||||||
|
FileSecretStoreError,
|
||||||
|
);
|
||||||
|
await expect(store.set({ instanceId: 'x', purpose: 'p' }, 'a\nb')).rejects.toThrow(
|
||||||
|
FileSecretStoreError,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
store.set({ instanceId: 'x', purpose: 'p' }, 'a'.repeat(16_385)),
|
||||||
|
).rejects.toThrow(FileSecretStoreError);
|
||||||
|
await expect(store.set({ instanceId: '', purpose: 'p' }, 'v')).rejects.toThrow(
|
||||||
|
SecretReferenceError,
|
||||||
|
);
|
||||||
|
await expect(store.get('https://evil.example/one')).rejects.toThrow(FileSecretStoreError);
|
||||||
|
await expect(store.get('secret-file://other-service/abc')).rejects.toThrow(
|
||||||
|
FileSecretStoreError,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the map private (0600) and leaves no temporary files behind', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||||
|
try {
|
||||||
|
const path = join(directory, 'nested', 'secrets.json');
|
||||||
|
const store = new FileSecretStore(path);
|
||||||
|
const reference = await store.set({ instanceId: 'x', purpose: 'p', slot: 's' }, 'secret');
|
||||||
|
if (process.platform !== 'win32')
|
||||||
|
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||||
|
expect(await readdir(join(directory, 'nested'))).toEqual(['secrets.json']);
|
||||||
|
const raw = await readFile(path, 'utf8');
|
||||||
|
expect(JSON.parse(raw)).toEqual({
|
||||||
|
[reference.slice('secret-file://multi-simadmin/'.length)]: 'secret',
|
||||||
|
});
|
||||||
|
expect(await store.get(reference)).toBe('secret');
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent writes so every mutation lands', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||||
|
try {
|
||||||
|
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||||
|
const references = await Promise.all(
|
||||||
|
Array.from({ length: 25 }, (_, index) =>
|
||||||
|
store.set({ instanceId: 'x', purpose: `p${index}`, slot: 's' }, `v${index}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [index, reference] of references.entries())
|
||||||
|
expect(await store.get(reference)).toBe(`v${index}`);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a corrupted or non-map payload instead of failing open', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||||
|
try {
|
||||||
|
const path = join(directory, 'secrets.json');
|
||||||
|
await writeFile(path, 'not json', 'utf8');
|
||||||
|
const store = new FileSecretStore(path);
|
||||||
|
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
|
||||||
|
FileSecretStoreError,
|
||||||
|
);
|
||||||
|
await writeFile(path, '{"account": 42}', 'utf8');
|
||||||
|
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
|
||||||
|
FileSecretStoreError,
|
||||||
|
);
|
||||||
|
expect(() => new FileSecretStore('relative/secrets.json')).toThrow(FileSecretStoreError);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secret backend selection', () => {
|
||||||
|
it('defaults to the keychain on darwin and the file store elsewhere', () => {
|
||||||
|
expect(resolveSecretBackend({}, 'darwin')).toBe('macos-keychain');
|
||||||
|
expect(resolveSecretBackend({}, 'linux')).toBe('secret-file');
|
||||||
|
expect(resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'secret-file' }, 'darwin')).toBe(
|
||||||
|
'secret-file',
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'vault' }, 'darwin'),
|
||||||
|
).toThrow(SecretBackendError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the matching store and refuses keychain off macOS', () => {
|
||||||
|
const directory = join(tmpdir(), 'msa-backend-selection');
|
||||||
|
expect(
|
||||||
|
createDefaultSecretStore({ env: {}, secretFilePath: join(directory, 's.json'), platform: 'linux' }),
|
||||||
|
).toBeInstanceOf(FileSecretStore);
|
||||||
|
expect(
|
||||||
|
createDefaultSecretStore({
|
||||||
|
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
|
||||||
|
secretFilePath: join(directory, 's.json'),
|
||||||
|
platform: 'darwin',
|
||||||
|
}),
|
||||||
|
).toBeInstanceOf(MacOSKeychainSecretStore);
|
||||||
|
expect(() =>
|
||||||
|
createDefaultSecretStore({
|
||||||
|
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
|
||||||
|
secretFilePath: join(directory, 's.json'),
|
||||||
|
platform: 'linux',
|
||||||
|
}),
|
||||||
|
).toThrow(SecretBackendError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file metadata probe accepts a missing or private file and rejects a public one', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-meta-'));
|
||||||
|
try {
|
||||||
|
const path = join(directory, 'secrets.json');
|
||||||
|
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
|
||||||
|
await writeFile(path, '{}\n', { mode: 0o600 });
|
||||||
|
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
await chmod(path, 0o644);
|
||||||
|
expect(await defaultSecretFileMetadataCheck(path)).toBe(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import { FileSecretStore } from './file-secret-store.js';
|
||||||
|
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||||
|
import type { SecretStore } from './secret-store.js';
|
||||||
|
|
||||||
|
export type SecretBackend = 'macos-keychain' | 'secret-file';
|
||||||
|
|
||||||
|
const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file'];
|
||||||
|
|
||||||
|
export class SecretBackendError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SecretBackendError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override,
|
||||||
|
* otherwise the macOS Keychain on darwin and the 0600 file store elsewhere.
|
||||||
|
*/
|
||||||
|
export function resolveSecretBackend(
|
||||||
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): SecretBackend {
|
||||||
|
const raw = env.MULTI_SIMADMIN_SECRET_BACKEND;
|
||||||
|
if (raw !== undefined) {
|
||||||
|
if (!BACKENDS.includes(raw as SecretBackend))
|
||||||
|
throw new SecretBackendError(
|
||||||
|
'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file',
|
||||||
|
);
|
||||||
|
return raw as SecretBackend;
|
||||||
|
}
|
||||||
|
return platform === 'darwin' ? 'macos-keychain' : 'secret-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDefaultSecretStoreOptions {
|
||||||
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
||||||
|
/** Absolute path of the file store map; required for the secret-file backend. */
|
||||||
|
readonly secretFilePath: string;
|
||||||
|
readonly platform?: NodeJS.Platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const backend = resolveSecretBackend(options.env, platform);
|
||||||
|
if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath);
|
||||||
|
if (platform !== 'darwin')
|
||||||
|
throw new SecretBackendError(
|
||||||
|
'The macos-keychain backend requires /usr/bin/security (macOS only)',
|
||||||
|
);
|
||||||
|
return new MacOSKeychainSecretStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretFileMetadata {
|
||||||
|
isFile(): boolean;
|
||||||
|
readonly mode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness metadata probe for the file backend. Availability only — this must
|
||||||
|
* never read secret material.
|
||||||
|
*/
|
||||||
|
export async function defaultSecretFileMetadataCheck(
|
||||||
|
filePath: string,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
statFn: (path: string) => Promise<SecretFileMetadata> = stat,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const info = await statFn(filePath);
|
||||||
|
if (!info.isFile()) return false;
|
||||||
|
// Windows keeps no POSIX mode bits; NTFS ACLs govern access there.
|
||||||
|
if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// A missing map is healthy: secrets are added lazily by instance setup.
|
||||||
|
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { SecretKey } from './secret-store.js';
|
||||||
|
|
||||||
|
export const SECRET_SERVICE = 'multi-simadmin';
|
||||||
|
|
||||||
|
export type SecretProvider = 'macos-keychain' | 'secret-file';
|
||||||
|
|
||||||
|
export const SECRET_PROVIDERS: readonly SecretProvider[] = ['macos-keychain', 'secret-file'];
|
||||||
|
|
||||||
|
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
||||||
|
|
||||||
|
export type SecretReferenceErrorCode = 'INVALID_KEY' | 'INVALID_REFERENCE';
|
||||||
|
|
||||||
|
export function parseSecretAccount(
|
||||||
|
account: string,
|
||||||
|
provider: SecretProvider,
|
||||||
|
): ParsedSecretReference {
|
||||||
|
let decoded: unknown;
|
||||||
|
try {
|
||||||
|
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
||||||
|
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||||
|
const [instanceId, purpose, slot] = decoded;
|
||||||
|
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||||
|
validateKey(key);
|
||||||
|
if (accountFor(key) !== account)
|
||||||
|
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
instanceId: key.instanceId,
|
||||||
|
purpose: key.purpose,
|
||||||
|
...(key.slot === undefined ? {} : { slot: key.slot }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateKey(key: SecretKey): void {
|
||||||
|
if (
|
||||||
|
typeof key.instanceId !== 'string' ||
|
||||||
|
key.instanceId.length === 0 ||
|
||||||
|
key.instanceId.length > 128 ||
|
||||||
|
!safeComponent.test(key.instanceId)
|
||||||
|
) {
|
||||||
|
throw new SecretReferenceError('INVALID_KEY', 'Secret key instance id is invalid');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof key.purpose !== 'string' ||
|
||||||
|
key.purpose.length === 0 ||
|
||||||
|
key.purpose.length > 64 ||
|
||||||
|
!safeComponent.test(key.purpose)
|
||||||
|
) {
|
||||||
|
throw new SecretReferenceError('INVALID_KEY', 'Secret key purpose is invalid');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key.slot !== undefined &&
|
||||||
|
(typeof key.slot !== 'string' ||
|
||||||
|
key.slot.length === 0 ||
|
||||||
|
key.slot.length > 64 ||
|
||||||
|
!safeComponent.test(key.slot))
|
||||||
|
) {
|
||||||
|
throw new SecretReferenceError('INVALID_KEY', 'Secret key slot is invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountFor(key: SecretKey): string {
|
||||||
|
validateKey(key);
|
||||||
|
const components =
|
||||||
|
key.slot === undefined
|
||||||
|
? [key.instanceId, key.purpose]
|
||||||
|
: [key.instanceId, key.purpose, key.slot];
|
||||||
|
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSecretReference {
|
||||||
|
readonly provider: SecretProvider;
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly purpose: string;
|
||||||
|
readonly slot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecretReferenceError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: SecretReferenceErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SecretReferenceError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accepts `keychain://multi-simadmin/<account>` and `secret-file://multi-simadmin/<account>`. */
|
||||||
|
export function parseSecretReference(reference: string): ParsedSecretReference {
|
||||||
|
if (typeof reference !== 'string' || reference.length > 512)
|
||||||
|
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||||
|
const match = /^([a-z-]+):\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||||
|
if (!match?.[1] || !match[2])
|
||||||
|
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||||
|
const scheme = match[1];
|
||||||
|
const provider = scheme === 'keychain' ? 'macos-keychain' : scheme === 'secret-file' ? 'secret-file' : undefined;
|
||||||
|
if (!provider)
|
||||||
|
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||||
|
return parseSecretAccount(match[2], provider);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export interface SecretKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SecretStore {
|
export interface SecretStore {
|
||||||
|
/** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */
|
||||||
|
readonly provider: string;
|
||||||
set(key: SecretKey, value: string): Promise<string>;
|
set(key: SecretKey, value: string): Promise<string>;
|
||||||
get(reference: string): Promise<string | undefined>;
|
get(reference: string): Promise<string | undefined>;
|
||||||
delete(reference: string): Promise<boolean>;
|
delete(reference: string): Promise<boolean>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret
|
|||||||
import { registerAutomationRoutes } from './automation-routes.js';
|
import { registerAutomationRoutes } from './automation-routes.js';
|
||||||
|
|
||||||
class MemorySecrets implements SecretStore {
|
class MemorySecrets implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
readonly values = new Map<string, string>();
|
readonly values = new Map<string, string>();
|
||||||
async set(key: SecretKey, value: string): Promise<string> {
|
async set(key: SecretKey, value: string): Promise<string> {
|
||||||
const reference = `memory://${key.instanceId}/${key.purpose}`;
|
const reference = `memory://${key.instanceId}/${key.purpose}`;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
|||||||
import { registerCentralNotificationRoutes } from './central-notification-routes.js';
|
import { registerCentralNotificationRoutes } from './central-notification-routes.js';
|
||||||
|
|
||||||
class MemorySecrets implements SecretStore {
|
class MemorySecrets implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
readonly values = new Map<string, string>();
|
readonly values = new Map<string, string>();
|
||||||
|
|
||||||
async set(key: { instanceId: string; purpose: string }, value: string) {
|
async set(key: { instanceId: string; purpose: string }, value: string) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
|||||||
import { registerInstanceRoutes } from './instance-routes.js';
|
import { registerInstanceRoutes } from './instance-routes.js';
|
||||||
|
|
||||||
class FakeStore implements SecretStore {
|
class FakeStore implements SecretStore {
|
||||||
|
readonly provider = 'macos-keychain';
|
||||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||||
return `keychain://multi-simadmin/${Buffer.from(
|
return `keychain://multi-simadmin/${Buffer.from(
|
||||||
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
|
|||||||
databasePath: join(directory, 'db.sqlite'),
|
databasePath: join(directory, 'db.sqlite'),
|
||||||
gatewayToken,
|
gatewayToken,
|
||||||
store: {
|
store: {
|
||||||
|
provider: 'macos-keychain',
|
||||||
set: async () => '',
|
set: async () => '',
|
||||||
get: async () => undefined,
|
get: async () => undefined,
|
||||||
delete: async () => false,
|
delete: async () => false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from './application/system/console-update-service.js';
|
} from './application/system/console-update-service.js';
|
||||||
import { openDatabase } from './infrastructure/database/database.js';
|
import { openDatabase } from './infrastructure/database/database.js';
|
||||||
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
||||||
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
import { createDefaultSecretStore, resolveSecretBackend, defaultSecretFileMetadataCheck } from './infrastructure/secrets/secret-backend.js';
|
||||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||||
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
|
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
|
||||||
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
|
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
|
||||||
@@ -21,6 +21,7 @@ export interface ProductionControlPlaneOptions {
|
|||||||
readonly upstream?: SafeControlPlaneUpstream;
|
readonly upstream?: SafeControlPlaneUpstream;
|
||||||
readonly gatewayToken?: string;
|
readonly gatewayToken?: string;
|
||||||
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||||
|
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
|
||||||
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
||||||
readonly runtimeVersion?: string;
|
readonly runtimeVersion?: string;
|
||||||
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
||||||
@@ -44,8 +45,10 @@ export function buildProductionControlPlane(
|
|||||||
const db = openDatabase(options.databasePath);
|
const db = openDatabase(options.databasePath);
|
||||||
try {
|
try {
|
||||||
migrateDatabase(db);
|
migrateDatabase(db);
|
||||||
const store = options.store ?? new MacOSKeychainSecretStore();
|
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
|
const secretFilePath = join(dirname(options.databasePath), 'secrets.json');
|
||||||
|
const store = options.store ?? createDefaultSecretStore({ env, secretFilePath });
|
||||||
|
const secretBackend = resolveSecretBackend(env);
|
||||||
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
|
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
|
||||||
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
|
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
|
||||||
const app = buildControlPlaneApp({
|
const app = buildControlPlaneApp({
|
||||||
@@ -71,7 +74,16 @@ export function buildProductionControlPlane(
|
|||||||
readiness: createProductionReadiness({
|
readiness: createProductionReadiness({
|
||||||
db,
|
db,
|
||||||
gatewayToken: options.gatewayToken ?? '',
|
gatewayToken: options.gatewayToken ?? '',
|
||||||
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
|
...(secretBackend === 'macos-keychain'
|
||||||
|
? {
|
||||||
|
keychainMetadataCheck:
|
||||||
|
options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
secretFileMetadataCheck:
|
||||||
|
options.secretFileMetadataCheck ??
|
||||||
|
(async () => defaultSecretFileMetadataCheck(secretFilePath)),
|
||||||
|
}),
|
||||||
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
|
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
|
||||||
...(options.recoveryMarkersCheck
|
...(options.recoveryMarkersCheck
|
||||||
? { recoveryMarkersCheck: options.recoveryMarkersCheck }
|
? { recoveryMarkersCheck: options.recoveryMarkersCheck }
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export interface ProductionReadinessOptions {
|
|||||||
readonly db: Database.Database;
|
readonly db: Database.Database;
|
||||||
readonly gatewayToken: string;
|
readonly gatewayToken: string;
|
||||||
/** Metadata/availability probe only. This callback must never retrieve a secret. */
|
/** Metadata/availability probe only. This callback must never retrieve a secret. */
|
||||||
readonly keychainMetadataCheck: () => boolean | Promise<boolean>;
|
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||||
|
/** Metadata/availability probe for the file secret backend (never reads secrets). */
|
||||||
|
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
|
||||||
/** Release gates are deliberately required: omitting one fails readiness closed. */
|
/** Release gates are deliberately required: omitting one fails readiness closed. */
|
||||||
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
||||||
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
||||||
@@ -56,11 +58,20 @@ export function createProductionReadiness(
|
|||||||
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
|
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
|
||||||
if (checks.gatewayAuth !== 'ready') ready = false;
|
if (checks.gatewayAuth !== 'ready') ready = false;
|
||||||
try {
|
try {
|
||||||
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
if (options.keychainMetadataCheck !== undefined) {
|
||||||
|
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
||||||
|
if (checks.keychain !== 'ready') ready = false;
|
||||||
|
} else if (options.secretFileMetadataCheck !== undefined) {
|
||||||
|
checks.secretFile = (await options.secretFileMetadataCheck()) ? 'ready' : 'unavailable';
|
||||||
|
if (checks.secretFile !== 'ready') ready = false;
|
||||||
|
} else {
|
||||||
|
checks.secrets = 'unconfigured';
|
||||||
|
ready = false;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
checks.keychain = 'unavailable';
|
checks.secrets = 'unavailable';
|
||||||
|
ready = false;
|
||||||
}
|
}
|
||||||
if (checks.keychain !== 'ready') ready = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = options.db
|
const row = options.db
|
||||||
|
|||||||
Reference in New Issue
Block a user