diff --git a/docs/features/vault.md b/docs/features/vault.md index eba52a6..f69786d 100644 --- a/docs/features/vault.md +++ b/docs/features/vault.md @@ -1,7 +1,6 @@ # Dew Vault Secret Manager -Dew Vault is a secret manager for your projects. It helps you keep secrets out of -version control by storing each secret as an encrypted file under `.project/vault`. +Dew Vault stores project secrets as encrypted files under `.project/vault`. By default the vault password is stored in `.project/secrets/dew.vault.password`. ## Config @@ -31,9 +30,9 @@ dew: description: Generate stable-looking unique IDs. ``` -`generators` maps a generator name (for example `postgres_password`) to a built-in -generator definition. Values under `config` are defaults and can be overridden per -run or stored in secret rotation metadata. +`generators` maps a generator name to a built-in generator definition. Values +under `config` are defaults and can be overridden per command invocation or in +secret rotation metadata. Built-in generator types are resolved inside Dew, so secrets can be generated without depending on host binaries. @@ -45,18 +44,16 @@ machine-friendly automation. ### Initialize Vault -Initialize the vault storage and metadata. +Initialize vault directories and password file. ```bash dew vault init - dew vault init --password-file .project/secrets/dew.vault.password +dew vault init --storage-dir .project/vault ``` ### List all secrets -List stored secrets. - ```bash dew vault list dew vault list --format json @@ -67,23 +64,15 @@ dew vault list --format json `set` stores or replaces a secret and optional metadata. ```bash -dew vault set # Prompts for secret value -dew vault set --env ENV_VAR_NAME # Uses value from environment variable -dew vault set --file /path/to/secret.txt # Uses value from file -echo "secret value" | dew vault set # Uses piped stdin - -# Include metadata for automated rotation requirements -dew vault set DB_PASSWORD --metadata '{"rotation":{"enabled":true,"generator":"postgres_password","length":64}}' -dew vault set DB_PASSWORD --metadata-file .project/vault/db_password.meta.json +dew vault set --name DB_PASSWORD --file /path/to/secret.txt +dew vault set --name DB_PASSWORD --env ENV_VAR_NAME ``` ### Get a secret -`get` retrieves a secret by name. - ```bash -dew vault get -dew vault get --format json +dew vault get --name DB_PASSWORD +dew vault get --name DB_PASSWORD --format json ``` ### Update a secret @@ -92,56 +81,43 @@ dew vault get --format json metadata only. ```bash -dew vault update --env ROLLED_PASSWORD -dew vault update --metadata '{"rotation":{"enabled":false}}' -dew vault update --metadata-file .project/vault/db_password.meta.json +dew vault update --name DB_PASSWORD --metadata '{"rotation":{"enabled":true,"generator":"postgres_password","length":64}}' +dew vault update --name DB_PASSWORD --metadata-file .project/vault/db_password.meta.json ``` ### Rename a secret -`rename` changes a secret identifier while preserving value and metadata. - ```bash -dew vault rename OLD_NAME NEW_NAME -dew vault rename OLD_NAME NEW_NAME --format json +dew vault rename --from OLD_NAME --to NEW_NAME +dew vault rename --from OLD_NAME --to NEW_NAME --format json ``` ### Generate a secret value -`generate` runs a built-in generator without writing to the vault by default. +`generate` uses configured generators without writing to the vault by default. ```bash -dew vault generate postgres_password --length 64 --include_symbols -dew vault generate jwt_secret --bytes 64 --encoding base64 -dew vault generate postgres_password --service payments --username app_user --format json -``` - -Pipe generated output directly into `set` when needed: - -```bash -dew vault generate postgres_password --service payments | dew vault set DB_PASSWORD +dew vault generate --generator postgres_password --arg length=64 --arg include_symbols=true +dew vault generate --generator jwt_secret --arg bytes=64 --arg encoding=base64 +dew vault generate --generator postgres_password --arg service=payments --arg username=app_user --format json ``` ### Rotate secrets `rotate` rewraps secrets with a new vault password when run without a name. -When run with a secret name, it rotates only that secret. For a secret with rotation -metadata (`rotation.enabled: true` and `rotation.generator`), Dew invokes that -configured built-in generator using the provided rotation values. +When run with a secret name, it rotates only that secret. ```bash dew vault rotate -dew vault rotate -dew vault rotate --format json +dew vault rotate --name DB_PASSWORD +dew vault rotate --name DB_PASSWORD --format json ``` ### Delete a secret -`delete` removes a secret and metadata from the vault. - ```bash -dew vault delete -dew vault delete --format json +dew vault delete --name DB_PASSWORD +dew vault delete --name DB_PASSWORD --format json ``` ### Metadata format for rotation-aware secrets @@ -151,7 +127,6 @@ Attach arbitrary metadata and include rotation policy details. Example shape: ```json { "rotation": { - "enabled": true, "generator": "postgres_password", "service": "payments", "username": "app_user", @@ -164,6 +139,6 @@ Attach arbitrary metadata and include rotation policy details. Example shape: Rotation flow: 1. Define a built-in generator in `dew.yaml` under `dew.vault.generators`. -2. Attach `rotation.generator` and generator args to the secret metadata. -3. Run `dew vault rotate ` to rotate one secret, or `dew vault rotate` - to rotate all configured secrets. +2. Attach `rotation.generator` and generator args to secret metadata. +3. Run `dew vault rotate --name ...` to rotate one secret, or `dew vault rotate` + to rotate all secrets. diff --git a/packages/vault/README.md b/packages/vault/README.md index d06625b..a020804 100644 --- a/packages/vault/README.md +++ b/packages/vault/README.md @@ -8,9 +8,46 @@ as MCP tools through `DewToolCommand`. ## Status -This package is currently a scaffold/stub to establish the command and MCP -wiring. It does not yet implement full encrypted secret storage or rotation logic. +This package implements encrypted secret storage, rotation-aware metadata, and +command handlers exposed as MCP tools. + +## Features + +- Encrypted secret storage under `.project/vault` using AES-GCM + PBKDF2. +- Vault password stored at `.project/secrets/dew.vault.password` by default. +- Configurable generators for secret rotation in `dew.vault.generators`. +- Built-in generator-backed `generate` command. +- Metadata-aware rotation and metadata persistence for rotation policy configuration. +- Rotation support: + - `vault rotate` rotates the vault password and rewraps every secret. + - `vault rotate --name ` regenerates a single secret value (via metadata-defined + generator when available). + +## Commands + +- `dew vault init` +- `dew vault get` +- `dew vault set` +- `dew vault update` +- `dew vault rename` +- `dew vault rotate` +- `dew vault generate` +- `dew vault list` +- `dew vault delete` + +Run `dew vault --format json` for machine-friendly output. ## License MIT — see [LICENSE](LICENSE). + +## Example metadata + +```yaml +rotation: + generator: postgres_password + length: 48 + include_symbols: false +``` + +Store it with `--metadata` or `--metadata-file` on `dew vault set`/`dew vault update`. diff --git a/packages/vault/lib/dew_vault.dart b/packages/vault/lib/dew_vault.dart index 19d49af..2ac60ca 100644 --- a/packages/vault/lib/dew_vault.dart +++ b/packages/vault/lib/dew_vault.dart @@ -1,6 +1,10 @@ library; export 'src/dew_vault_base.dart'; +export 'src/vault_config.dart'; +export 'src/vault_crypto.dart'; +export 'src/vault_generators.dart'; +export 'src/vault_store.dart'; import 'package:dew_core/dew_core.dart'; import 'package:file/file.dart'; @@ -14,6 +18,6 @@ void registerCommands( CommandRegistry registry, { FileSystem fs = const LocalFileSystem(), }) { - registry.register(VaultCommand()); + registry.register(VaultCommand(fs: fs)); registry.registerInitHook(VaultInitHook(fs: fs)); } diff --git a/packages/vault/lib/src/command_output.dart b/packages/vault/lib/src/command_output.dart index 6290cd2..918fc5d 100644 --- a/packages/vault/lib/src/command_output.dart +++ b/packages/vault/lib/src/command_output.dart @@ -1,4 +1,8 @@ import 'dart:convert'; +import 'dart:io'; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; String renderVaultOutput({ String format = 'default', @@ -28,3 +32,131 @@ String formatFromArgs(Map args) { if (value == null || value.toString().trim().isEmpty) return 'default'; return value.toString(); } + +String resolveProjectPath(String projectRoot, String value) { + return p.isAbsolute(value) ? value : p.join(projectRoot, value); +} + +Future readSecretInput( + Map args, { + required FileSystem fs, + bool required = true, + bool allowStdin = true, + String? projectRoot, +}) async { + final envVar = args['env']?.toString(); + if (envVar != null && envVar.trim().isNotEmpty) { + final envValue = Platform.environment[envVar]; + if (envValue == null || envValue.isEmpty) { + throw ArgumentError( + 'Environment variable "$envVar" is not set or is empty.', + ); + } + return envValue; + } + + final filePath = args['file']?.toString(); + if (filePath != null && filePath.trim().isNotEmpty) { + final resolved = projectRoot == null + ? filePath + : resolveProjectPath(projectRoot, filePath); + final value = await fs.file(resolved).readAsString(); + if (value.trim().isEmpty) { + throw ArgumentError('File "$filePath" is empty.'); + } + return value.trimRight(); + } + + if (!allowStdin || stdin.hasTerminal) { + if (!required) return null; + throw ArgumentError('Missing secret value. Use --env, --file, or pipe input.'); + } + + final input = (await stdin.transform(utf8.decoder).join()).trimRight(); + if (input.trim().isEmpty) { + if (!required) return null; + throw ArgumentError('Piped input was empty.'); + } + return input; +} + +Map mergeMetadata( + Map base, + Map? updates, +) { + final merged = Map.from(base); + if (updates == null) return merged; + for (final entry in updates.entries) { + merged[entry.key] = entry.value; + } + return merged; +} + +Future> parseMetadataFromArgs({ + required Map args, + required FileSystem fs, + String? projectRoot, +}) async { + final inline = args['metadata']?.toString(); + final filePath = args['metadata-file']?.toString(); + + if (inline != null && filePath != null) { + throw ArgumentError('Use either --metadata or --metadata-file, not both.'); + } + + String raw = ''; + if (inline != null) { + raw = inline; + } else if (filePath != null) { + final resolved = projectRoot == null ? filePath : resolveProjectPath(projectRoot, filePath); + raw = await fs.file(resolved).readAsString(); + } + if (raw.isEmpty) return {}; + + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw ArgumentError('Metadata must be a JSON object.'); + } + return Map.fromEntries( + decoded.entries.map((e) => MapEntry(e.key.toString(), e.value)), + ); +} + +Map parseGeneratorOptionPairs(dynamic value) { + final values = {}; + final rawValues = switch (value) { + List list => list, + null => const [], + dynamic single => [single], + }; + + for (final item in rawValues) { + if (item == null) continue; + final entry = item.toString(); + if (entry.trim().isEmpty) continue; + final splitAt = entry.indexOf('='); + final key = splitAt == -1 + ? entry.trim() + : entry.substring(0, splitAt).trim(); + final rawValue = splitAt == -1 ? null : entry.substring(splitAt + 1); + if (key.isEmpty) continue; + values[key] = _coerceValue(rawValue?.trim()); + } + return values; +} + +dynamic _coerceValue(String? value) { + if (value == null) return true; + final lower = value.toLowerCase(); + if (lower == 'true') return true; + if (lower == 'false') return false; + if (int.tryParse(value) != null) return int.parse(value); + if (double.tryParse(value) != null) return double.parse(value); + if ((value.startsWith('{') && value.endsWith('}')) || + (value.startsWith('[') && value.endsWith(']'))) { + try { + return jsonDecode(value); + } catch (_) {} + } + return value; +} diff --git a/packages/vault/lib/src/commands/delete_command.dart b/packages/vault/lib/src/commands/delete_command.dart index 4112770..13daadb 100644 --- a/packages/vault/lib/src/commands/delete_command.dart +++ b/packages/vault/lib/src/commands/delete_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import '../vault_config.dart'; +import '../vault_store.dart'; import '../command_output.dart'; class DeleteCommand extends DewCommand with DewToolCommand { - DeleteCommand() { + final FileSystem _fs; + + DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'name', @@ -32,9 +38,18 @@ class DeleteCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final format = formatFromArgs(args); final secretName = requireStringArg(args, 'name'); + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + await store.delete(secretName); + return renderVaultOutput( format: format, - message: 'Delete stub executed.', + message: 'Deleted.', json: {'secret': secretName}, ); } diff --git a/packages/vault/lib/src/commands/generate_command.dart b/packages/vault/lib/src/commands/generate_command.dart index 9203195..0ec33cb 100644 --- a/packages/vault/lib/src/commands/generate_command.dart +++ b/packages/vault/lib/src/commands/generate_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../command_output.dart'; +import '../vault_config.dart'; +import '../vault_generators.dart'; class GenerateCommand extends DewCommand with DewToolCommand { - GenerateCommand() { + final FileSystem _fs; + + GenerateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'generator', @@ -36,14 +42,23 @@ class GenerateCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final format = formatFromArgs(args); final generator = requireStringArg(args, 'generator'); - final overrides = args['arg'] as List?; + final rawOverrides = parseGeneratorOptionPairs(args['arg']); + final context = await ProjectContext.find(fs: _fs); + final configuredGenerators = context.config.vault.generators; + + final value = VaultGenerators.generateByName( + nameOrType: generator, + generators: configuredGenerators, + options: rawOverrides, + ); + return renderVaultOutput( format: format, - message: 'Generate stub output.', + message: value, json: { 'generator': generator, - 'options': overrides == null ? const [] : overrides, - 'value': '', + 'options': rawOverrides, + 'value': value, }, ); } diff --git a/packages/vault/lib/src/commands/get_command.dart b/packages/vault/lib/src/commands/get_command.dart index 282f53a..238abae 100644 --- a/packages/vault/lib/src/commands/get_command.dart +++ b/packages/vault/lib/src/commands/get_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../command_output.dart'; +import '../vault_config.dart'; +import '../vault_store.dart'; class GetCommand extends DewCommand with DewToolCommand { - GetCommand() { + final FileSystem _fs; + + GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('name', abbr: 'n', mandatory: true, help: 'Secret name.') ..addOption( @@ -27,11 +33,28 @@ class GetCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final format = formatFromArgs(args); final secretName = requireStringArg(args, 'name'); + + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + + final record = await store.read(secretName); + if (record == null) { + throw ArgumentError('Secret "$secretName" not found.'); + } + return renderVaultOutput( format: format, - message: 'Get stub value: [redacted].', - json: {'secret': secretName}, + message: record.value, + json: { + 'name': record.name, + 'value': record.value, + 'metadata': record.metadata, + }, ); } - } diff --git a/packages/vault/lib/src/commands/init_command.dart b/packages/vault/lib/src/commands/init_command.dart index b8b2679..6403917 100644 --- a/packages/vault/lib/src/commands/init_command.dart +++ b/packages/vault/lib/src/commands/init_command.dart @@ -1,9 +1,14 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import '../vault_store.dart'; import '../command_output.dart'; class VaultInitCommand extends DewCommand with DewToolCommand { - VaultInitCommand() { + final FileSystem _fs; + + VaultInitCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'password-file', @@ -36,14 +41,23 @@ class VaultInitCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { final format = formatFromArgs(args); - final passwordFile = requireStringArg(args, 'password-file'); - final storageDir = requireStringArg(args, 'storage-dir'); + final passwordFile = args['password-file']?.toString() ?? '.project/secrets/dew.vault.password'; + final storageDir = args['storage-dir']?.toString() ?? '.project/vault'; + + final context = await ProjectContext.find(fs: _fs); + final store = VaultStore( + storageDir: resolveProjectPath(context.root, storageDir), + passwordFilePath: resolveProjectPath(context.root, passwordFile), + fs: context.fs, + ); + await store.initialize(); + return renderVaultOutput( format: format, - message: 'Vault init stub completed.', + message: 'Vault initialised.', json: { - 'password_file': passwordFile, - 'storage_dir': storageDir, + 'password_file': store.passwordFilePath, + 'storage_dir': store.storageDir, 'initialized': true, }, ); diff --git a/packages/vault/lib/src/commands/list_command.dart b/packages/vault/lib/src/commands/list_command.dart index ac35a71..0ebeed2 100644 --- a/packages/vault/lib/src/commands/list_command.dart +++ b/packages/vault/lib/src/commands/list_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import '../vault_config.dart'; +import '../vault_store.dart'; import '../command_output.dart'; class ListCommand extends DewCommand with DewToolCommand { - ListCommand() { + final FileSystem _fs; + + ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser.addOption( 'format', defaultsTo: 'default', @@ -24,10 +30,30 @@ class ListCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { final format = formatFromArgs(args); + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + final names = await store.listSecretNames(); + if (names.isEmpty) { + return renderVaultOutput( + format: format, + message: 'No secrets found.', + json: {'secrets': const [], 'count': 0}, + ); + } + + final output = names.join('\n'); return renderVaultOutput( format: format, - message: 'No secrets found (stubbed vault).', - json: {'secrets': [], 'count': 0}, + message: output, + json: { + 'secrets': names, + 'count': names.length, + }, ); } diff --git a/packages/vault/lib/src/commands/rename_command.dart b/packages/vault/lib/src/commands/rename_command.dart index 58c2f90..e796ad5 100644 --- a/packages/vault/lib/src/commands/rename_command.dart +++ b/packages/vault/lib/src/commands/rename_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../command_output.dart'; +import '../vault_config.dart'; +import '../vault_store.dart'; class RenameCommand extends DewCommand with DewToolCommand { - RenameCommand() { + final FileSystem _fs; + + RenameCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'from', @@ -37,9 +43,18 @@ class RenameCommand extends DewCommand with DewToolCommand { final format = formatFromArgs(args); final from = requireStringArg(args, 'from'); final to = requireStringArg(args, 'to'); + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + await store.rename(from, to); + return renderVaultOutput( format: format, - message: 'Rename stub executed.', + message: 'Renamed.', json: {'from': from, 'to': to}, ); } diff --git a/packages/vault/lib/src/commands/rotate_command.dart b/packages/vault/lib/src/commands/rotate_command.dart index 5cf1577..71bc5d0 100644 --- a/packages/vault/lib/src/commands/rotate_command.dart +++ b/packages/vault/lib/src/commands/rotate_command.dart @@ -1,9 +1,16 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../command_output.dart'; +import '../vault_config.dart'; +import '../vault_generators.dart'; +import '../vault_store.dart'; class RotateCommand extends DewCommand with DewToolCommand { - RotateCommand() { + final FileSystem _fs; + + RotateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('name', help: 'Secret name to rotate; omit to rotate vault password.') ..addOption( @@ -19,7 +26,7 @@ class RotateCommand extends DewCommand with DewToolCommand { @override final String description = - 'Rotate vault password or a single secret value (stub).'; + 'Rotate vault password or a single secret value.'; @override final String toolName = 'vault_rotate_secret'; @@ -28,11 +35,74 @@ class RotateCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final format = formatFromArgs(args); final target = args['name']?.toString(); + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + + if (target == null || target.trim().isEmpty) { + final rotatedCount = await store.rotateVaultPassword(); + return renderVaultOutput( + format: format, + message: 'Vault password rotated.', + json: { + 'scope': 'vault', + 'rotated_count': rotatedCount, + }, + ); + } + + final record = await store.read(target); + if (record == null) throw ArgumentError('Secret "$target" not found.'); + final value = _rotateValue(config, record); + await store.write(target, value, metadata: record.metadata); + return renderVaultOutput( format: format, - message: 'Rotate stub executed.', - json: {'target': target ?? '', 'scope': target == null ? 'vault' : 'secret'}, + message: 'Secret rotated.', + json: { + 'scope': 'secret', + 'name': target, + }, ); } + String _rotateValue( + VaultConfig vaultConfig, + VaultSecretRecord record, + ) { + final rotationConfig = record.metadata['rotation']; + if (rotationConfig is Map) { + final configEntries = Map.fromEntries( + rotationConfig.entries.map((e) => MapEntry(e.key.toString(), e.value)), + ); + final generatorName = configEntries.remove('generator')?.toString(); + configEntries.remove('enabled'); + + if (generatorName != null && generatorName.isNotEmpty) { + if (vaultConfig.generators.containsKey(generatorName) || + [ + 'random_password', + 'random_token', + 'uuid_v4', + ].contains(generatorName)) { + return VaultGenerators.generateByName( + nameOrType: generatorName, + generators: vaultConfig.generators, + options: configEntries, + ); + } + throw ArgumentError('Unknown rotation generator "$generatorName".'); + } + + if (configEntries.isNotEmpty) { + return VaultGenerators.generate('random_password', configEntries); + } + } + + return VaultGenerators.generate('random_password', {}); + } } diff --git a/packages/vault/lib/src/commands/set_command.dart b/packages/vault/lib/src/commands/set_command.dart index 5b47286..e9ece6c 100644 --- a/packages/vault/lib/src/commands/set_command.dart +++ b/packages/vault/lib/src/commands/set_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../command_output.dart'; +import '../vault_config.dart'; +import '../vault_store.dart'; class SetCommand extends DewCommand with DewToolCommand { - SetCommand() { + final FileSystem _fs; + + SetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'name', @@ -36,17 +42,37 @@ class SetCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final format = formatFromArgs(args); final secretName = requireStringArg(args, 'name'); - final source = - args['env'] ?? args['file'] ?? args['metadata'] ?? args['metadata-file']; + + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + + final metadata = await parseMetadataFromArgs( + args: args, + fs: context.fs, + projectRoot: context.root, + ); + final value = await readSecretInput( + args, + fs: context.fs, + projectRoot: context.root, + required: true, + allowStdin: false, + ); + + await store.write(secretName, value!, metadata: metadata); + return renderVaultOutput( format: format, - message: 'Set stub executed.', + message: 'Stored secret.', json: { - 'secret': secretName, - 'source': source == null ? 'interactive' : source.toString(), - 'status': 'set', + 'name': secretName, + 'metadata': metadata, }, ); } - } diff --git a/packages/vault/lib/src/commands/update_command.dart b/packages/vault/lib/src/commands/update_command.dart index aee2fd2..626d866 100644 --- a/packages/vault/lib/src/commands/update_command.dart +++ b/packages/vault/lib/src/commands/update_command.dart @@ -1,9 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import '../vault_config.dart'; +import '../vault_store.dart'; import '../command_output.dart'; class UpdateCommand extends DewCommand with DewToolCommand { - UpdateCommand() { + final FileSystem _fs; + + UpdateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'name', @@ -36,13 +42,60 @@ class UpdateCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final format = formatFromArgs(args); final secretName = requireStringArg(args, 'name'); - final mode = args['env'] != null || args['file'] != null ? 'value-update' : 'metadata-only'; + final hasValueSource = args['env'] != null || args['file'] != null; + final hasMetadataSource = + args['metadata'] != null || args['metadata-file'] != null; + + if (!hasValueSource && !hasMetadataSource) { + throw ArgumentError( + 'No update provided. Use --env or --file and/or --metadata / --metadata-file.', + ); + } + + final context = await ProjectContext.find(fs: _fs); + final config = context.config.vault; + final store = VaultStore( + storageDir: resolveProjectPath(context.root, config.storageDir), + passwordFilePath: resolveProjectPath(context.root, config.passwordFile), + fs: context.fs, + ); + + final existing = await store.read(secretName); + if (existing == null) throw ArgumentError('Secret "$secretName" not found.'); + + Map updatedMetadata; + if (hasMetadataSource) { + final provided = await parseMetadataFromArgs( + args: args, + fs: context.fs, + projectRoot: context.root, + ); + updatedMetadata = provided.isEmpty ? {} : mergeMetadata(existing.metadata, provided); + } else { + updatedMetadata = existing.metadata; + } + + String? value; + if (hasValueSource) { + value = await readSecretInput( + args, + fs: context.fs, + projectRoot: context.root, + required: true, + allowStdin: false, + ); + } + + final finalValue = value ?? existing.value; + await store.write(secretName, finalValue, metadata: updatedMetadata); + return renderVaultOutput( format: format, - message: 'Update stub executed.', + message: 'Secret updated.', json: { - 'secret': secretName, - 'mode': mode, + 'name': secretName, + 'updated_metadata': hasMetadataSource, + 'updated_value': hasValueSource, }, ); } diff --git a/packages/vault/lib/src/dew_vault_base.dart b/packages/vault/lib/src/dew_vault_base.dart index 99a30b9..243bf0f 100644 --- a/packages/vault/lib/src/dew_vault_base.dart +++ b/packages/vault/lib/src/dew_vault_base.dart @@ -9,19 +9,21 @@ import 'commands/rename_command.dart'; import 'commands/rotate_command.dart'; import 'commands/set_command.dart'; import 'commands/update_command.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; /// Top-level CLI command for all Vault operations. class VaultCommand extends DewCommand { - VaultCommand() { - addSubcommand(VaultInitCommand()); - addSubcommand(ListCommand()); - addSubcommand(SetCommand()); - addSubcommand(GetCommand()); - addSubcommand(UpdateCommand()); - addSubcommand(RenameCommand()); - addSubcommand(RotateCommand()); - addSubcommand(GenerateCommand()); - addSubcommand(DeleteCommand()); + VaultCommand({FileSystem fs = const LocalFileSystem()}) { + addSubcommand(VaultInitCommand(fs: fs)); + addSubcommand(ListCommand(fs: fs)); + addSubcommand(SetCommand(fs: fs)); + addSubcommand(GetCommand(fs: fs)); + addSubcommand(UpdateCommand(fs: fs)); + addSubcommand(RenameCommand(fs: fs)); + addSubcommand(RotateCommand(fs: fs)); + addSubcommand(GenerateCommand(fs: fs)); + addSubcommand(DeleteCommand(fs: fs)); } @override diff --git a/packages/vault/lib/src/vault_config.dart b/packages/vault/lib/src/vault_config.dart new file mode 100644 index 0000000..669b0b6 --- /dev/null +++ b/packages/vault/lib/src/vault_config.dart @@ -0,0 +1,99 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +class VaultGeneratorDefinition { + final String type; + final String? description; + final Map config; + + const VaultGeneratorDefinition({ + required this.type, + this.description, + required this.config, + }); +} + +class VaultConfig { + final String passwordFile; + final String storageDir; + final Map generators; + + const VaultConfig({ + required this.passwordFile, + required this.storageDir, + required this.generators, + }); +} + +extension VaultDewConfig on DewConfig { + static const _defaultPasswordFile = '.project/secrets/dew.vault.password'; + static const _defaultStorageDir = '.project/vault'; + + VaultConfig get vault { + final dewSection = _coerceMap(_asMap(raw['dew'])); + final vaultSection = _coerceMap(dewSection['vault']); + + final storageDir = _asString( + vaultSection['storage_dir'], + fallback: _defaultStorageDir, + ); + final passwordFile = _asString( + vaultSection['password_file'], + fallback: _defaultPasswordFile, + ); + + final generators = {}; + final generatorSection = vaultSection['generators']; + if (generatorSection is Map) { + for (final entry in generatorSection.entries) { + if (entry.key == null) continue; + final name = entry.key.toString(); + final definition = _coerceMap(entry.value); + final type = _asString(definition['type'], fallback: ''); + if (type.isEmpty) continue; + generators[name] = VaultGeneratorDefinition( + type: type, + description: _asString(definition['description'], fallback: null), + config: _coerceMap(definition['config']), + ); + } + } + + return VaultConfig( + passwordFile: passwordFile, + storageDir: storageDir, + generators: generators, + ); + } +} + +extension VaultDirs on ProjectDirs { + String get vaultStorage => p.join(project, 'vault'); + String get vaultSecrets => p.join(project, 'secrets'); +} + +String _asString(dynamic value, {String? fallback}) { + if (value == null) return fallback ?? ''; + return value.toString(); +} + +Map _coerceMap(dynamic input) { + if (input == null) return {}; + if (input is Map) { + return input.map((key, value) => MapEntry(key.toString(), _coerceValue(value))); + } + return {}; +} + +Map _coerceMapOfDynamic(dynamic input) { + return _coerceMap(input); +} + +dynamic _coerceValue(dynamic value) { + if (value is Map) return _coerceMap(value); + if (value is List) return value.map(_coerceValue).toList(); + return value; +} + +Map _asMap(dynamic value) => _coerceMapOfDynamic(value); + diff --git a/packages/vault/lib/src/vault_crypto.dart b/packages/vault/lib/src/vault_crypto.dart new file mode 100644 index 0000000..eeabc8b --- /dev/null +++ b/packages/vault/lib/src/vault_crypto.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart'; + +class VaultCrypto { + static const int defaultIterations = 200000; + static const int defaultKeyLength = 32; + static const int defaultSaltLength = 16; + static const int defaultNonceLength = 12; + static const int defaultVersion = 1; + static const int gcmMacLength = 128; + + static Map encryptToEnvelope( + String value, { + required String password, + int iterations = defaultIterations, + }) { + final plainBytes = utf8.encode(value); + final salt = _randomBytes(defaultSaltLength); + final nonce = _randomBytes(defaultNonceLength); + final key = _deriveKey(password: password, salt: salt, iterations: iterations); + + final cipher = GCMBlockCipher(AESEngine()); + final params = AEADParameters( + KeyParameter(key), + gcmMacLength, + nonce, + Uint8List(0), + ); + cipher.init(true, params); + + final encrypted = cipher.process(Uint8List.fromList(plainBytes)); + return { + 'version': defaultVersion, + 'kdf': { + 'name': 'PBKDF2-HMAC-SHA256', + 'iterations': iterations, + 'salt': base64Encode(salt), + }, + 'nonce': base64Encode(nonce), + 'ciphertext': base64Encode(encrypted), + }; + } + + static String decryptFromEnvelope( + Map payload, { + required String password, + }) { + final version = _asInt(payload['version'], fallback: defaultVersion); + if (version != defaultVersion) { + throw ArgumentError('Unsupported vault payload format: $version'); + } + + final kdf = _asMap(payload['kdf']); + final iterations = _asInt(kdf['iterations'], fallback: defaultIterations); + final salt = _decodeBytes(kdf['salt'], field: 'salt'); + final nonce = _decodeBytes(payload['nonce'], field: 'nonce'); + final ciphertext = _decodeBytes(payload['ciphertext'], field: 'ciphertext'); + + final key = _deriveKey( + password: password, + salt: salt, + iterations: iterations, + ); + + try { + final cipher = GCMBlockCipher(AESEngine()); + final params = AEADParameters( + KeyParameter(key), + gcmMacLength, + nonce, + Uint8List(0), + ); + cipher.init(false, params); + final plaintext = cipher.process(ciphertext); + return utf8.decode(plaintext); + } catch (error) { + throw ArgumentError('Unable to decrypt secret value. $error'); + } + } + + static String randomPassword({int length = 64}) { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#\$%^&*()-_=+[]{};:,.?/|'; + final random = Random.secure(); + return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join(); + } + + static Uint8List _deriveKey({ + required String password, + required Uint8List salt, + required int iterations, + }) { + final params = Pbkdf2Parameters(salt, iterations, defaultKeyLength); + final derivator = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64)); + derivator.init(params); + return derivator.process(Uint8List.fromList(utf8.encode(password))); + } + + static Uint8List _randomBytes(int length) { + final random = Random.secure(); + return Uint8List.fromList( + List.generate(length, (_) => random.nextInt(256)), + ); + } + + static Map _asMap(dynamic value) { + if (value is Map) return Map.from(value); + return {}; + } + + static int _asInt(dynamic value, {required int fallback}) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; + } + + static Uint8List _decodeBytes(dynamic value, {required String field}) { + if (value is! String) { + throw ArgumentError('Invalid vault payload field "$field".'); + } + return Uint8List.fromList(base64Decode(value)); + } +} diff --git a/packages/vault/lib/src/vault_generators.dart b/packages/vault/lib/src/vault_generators.dart new file mode 100644 index 0000000..ee81b2e --- /dev/null +++ b/packages/vault/lib/src/vault_generators.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'vault_config.dart'; + +class VaultGenerators { + static const _passwordCharsetLower = 'abcdefghijklmnopqrstuvwxyz'; + static const _passwordCharsetUpper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + static const _passwordCharsetDigits = '0123456789'; + static const _passwordCharsetSymbols = '!@#\$%^&*()-_=+[]{};:,.?/|'; + + static String generate(String type, Map options) { + return switch (type) { + 'random_password' => randomPassword(options), + 'random_token' => randomToken(options), + 'uuid_v4' => uuidV4(options), + _ => throw ArgumentError('Unknown generator type "$type".'), + }; + } + + static String generateByName({ + required String nameOrType, + required Map generators, + Map? options, + }) { + final configured = generators[nameOrType]; + if (configured != null) { + final merged = Map.from(configured.config); + if (options != null) { + for (final entry in options.entries) { + merged[entry.key] = entry.value; + } + } + return generate(configured.type, merged); + } + + return generate(nameOrType, options ?? const {}); + } + + static String randomPassword(Map options) { + final length = _readIntOption(options['length'], fallback: 32); + final includeLower = _readBoolOption(options['include_lowercase'], fallback: true); + final includeUpper = _readBoolOption(options['include_uppercase'], fallback: true); + final includeNumbers = _readBoolOption(options['include_numbers'], fallback: true); + final includeSymbols = _readBoolOption(options['include_symbols'], fallback: false); + + final chars = StringBuffer(); + if (includeLower) { + chars.write(_passwordCharsetLower); + } + if (includeUpper) { + chars.write(_passwordCharsetUpper); + } + if (includeNumbers) { + chars.write(_passwordCharsetDigits); + } + if (includeSymbols) { + chars.write(_passwordCharsetSymbols); + } + + if (chars.isEmpty) { + throw ArgumentError('Generator requires at least one enabled character set.'); + } + final charset = chars.toString(); + final random = Random.secure(); + return List.generate(length, (_) => charset[random.nextInt(charset.length)]).join(); + } + + static String randomToken(Map options) { + final bytes = _readIntOption(options['bytes'], fallback: 32); + final encoding = _readStringOption( + options['encoding'], + fallback: 'base64', + ).toLowerCase(); + final randomBytes = _randomBytes(bytes); + + switch (encoding) { + case 'base64': + return base64.encode(randomBytes); + case 'base64url': + case 'url': + case 'urlsafe': + case 'url-safe': + return base64Url.encode(randomBytes).replaceAll('=', ''); + case 'hex': + case 'hexadecimal': + return randomBytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + default: + throw ArgumentError('Unknown random_token encoding "$encoding".'); + } + } + + static String uuidV4(Map options) { + final bytes = _randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + final buffer = StringBuffer(); + buffer + ..write(hex.substring(0, 8)) + ..write('-') + ..write(hex.substring(8, 12)) + ..write('-') + ..write(hex.substring(12, 16)) + ..write('-') + ..write(hex.substring(16, 20)) + ..write('-') + ..write(hex.substring(20)); + return buffer.toString(); + } + + static int _readIntOption(dynamic value, {required int fallback}) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; + } + + static bool _readBoolOption(dynamic value, {required bool fallback}) { + if (value is bool) return value; + if (value is String) { + if (value.toLowerCase() == 'true') return true; + if (value.toLowerCase() == 'false') return false; + } + return fallback; + } + + static String _readStringOption(dynamic value, {required String fallback}) { + if (value == null) return fallback; + return value.toString(); + } + + static Uint8List _randomBytes(int length) { + final random = Random.secure(); + return Uint8List.fromList( + List.generate(length, (_) => random.nextInt(256)), + ); + } +} diff --git a/packages/vault/lib/src/vault_store.dart b/packages/vault/lib/src/vault_store.dart new file mode 100644 index 0000000..5bbb433 --- /dev/null +++ b/packages/vault/lib/src/vault_store.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:path/path.dart' as p; + +import 'vault_crypto.dart'; + +class VaultSecretRecord { + final String name; + final String value; + final Map metadata; + + const VaultSecretRecord({ + required this.name, + required this.value, + required this.metadata, + }); +} + +class VaultStore { + static final _namePattern = RegExp(r'^[A-Za-z0-9._-]+$'); + static const _secretSuffix = '.vault'; + static const _metadataSuffix = '.meta.json'; + + final String storageDir; + final String passwordFilePath; + final FileSystem fs; + final int iterations; + + const VaultStore({ + required this.storageDir, + required this.passwordFilePath, + this.fs = const LocalFileSystem(), + this.iterations = VaultCrypto.defaultIterations, + }); + + Future initialize() async { + await fs.directory(storageDir).create(recursive: true); + await fs.directory(p.dirname(passwordFilePath)).create(recursive: true); + await ensurePassword(); + } + + Future exists(String name) async { + _validateName(name); + return fs.file(_secretPath(name)).exists(); + } + + Future read(String name) async { + _validateName(name); + final secretFile = fs.file(_secretPath(name)); + if (!await secretFile.exists()) return null; + + final payloadText = await secretFile.readAsString(); + final payload = jsonDecode(payloadText); + if (payload is! Map) { + throw ArgumentError('Secret "$name" is malformed.'); + } + + final password = await readPassword(); + final value = VaultCrypto.decryptFromEnvelope( + Map.from(payload), + password: password, + ); + + final metadata = await _readMetadata(name); + return VaultSecretRecord(name: name, value: value, metadata: metadata); + } + + Future write( + String name, + String value, { + Map? metadata, + }) async { + _validateName(name); + if (value.trim().isEmpty) { + throw ArgumentError('Secret value must not be empty.'); + } + + await initialize(); + final password = await readPassword(); + final envelope = VaultCrypto.encryptToEnvelope( + value, + password: password, + iterations: iterations, + ); + await fs.file(_secretPath(name)).writeAsString( + const JsonEncoder.withIndent(' ').convert(envelope), + ); + + await _writeMetadata(name, metadata); + } + + Future delete(String name) async { + _validateName(name); + final secretFile = fs.file(_secretPath(name)); + if (!await secretFile.exists()) { + throw ArgumentError('Secret "$name" not found.'); + } + await secretFile.delete(); + final metadataFile = fs.file(_metadataPath(name)); + if (await metadataFile.exists()) { + await metadataFile.delete(); + } + } + + Future rename(String from, String to) async { + _validateName(from); + _validateName(to); + if (from == to) return; + + final fromSecret = fs.file(_secretPath(from)); + if (!await fromSecret.exists()) { + throw ArgumentError('Secret "$from" not found.'); + } + final toSecret = fs.file(_secretPath(to)); + if (await toSecret.exists()) { + throw ArgumentError('Secret "$to" already exists.'); + } + + final toMetadata = fs.file(_metadataPath(to)); + final fromMetadata = fs.file(_metadataPath(from)); + + await fromSecret.rename(toSecret.path); + if (await fromMetadata.exists()) { + if (await toMetadata.exists()) { + throw ArgumentError('Metadata for "$to" already exists.'); + } + await fromMetadata.rename(toMetadata.path); + } + } + + Future rotateVaultPassword() async { + final names = await listSecretNames(); + if (names.isEmpty) { + return 0; + } + + final password = await readPassword(); + final records = []; + for (final name in names) { + final payload = await _readWithPassword(name, password: password); + if (payload == null) { + throw ArgumentError('Failed to read secret "$name" for rotation.'); + } + records.add(payload); + } + + final newPassword = VaultCrypto.randomPassword(length: 64); + for (final record in records) { + await _writeWithPassword( + record.name, + value: record.value, + password: newPassword, + metadata: record.metadata, + ); + } + await writePassword(newPassword); + return records.length; + } + + Future> listSecretNames() async { + final dir = fs.directory(storageDir); + if (!await dir.exists()) return const []; + + final names = []; + await for (final file in dir.list()) { + if (file is! File) continue; + if (p.extension(file.path) != _secretSuffix) continue; + final name = p.basenameWithoutExtension(file.path); + if (name.isNotEmpty) names.add(name); + } + names.sort(); + return names; + } + + Future ensurePassword() async { + final file = fs.file(passwordFilePath); + if (!await file.exists()) { + final password = VaultCrypto.randomPassword(length: 64); + await writePassword(password); + return password; + } + final existing = await file.readAsString(); + if (existing.trim().isNotEmpty) { + return existing.trim(); + } + final password = VaultCrypto.randomPassword(length: 64); + await writePassword(password); + return password; + } + + Future readPassword() async { + final file = fs.file(passwordFilePath); + if (!await file.exists()) { + throw ArgumentError('Vault password file not found at $passwordFilePath.'); + } + final password = (await file.readAsString()).trim(); + if (password.isEmpty) { + throw ArgumentError('Vault password file is empty at $passwordFilePath.'); + } + return password; + } + + Future writePassword(String password) async { + await fs.directory(p.dirname(passwordFilePath)).create(recursive: true); + await fs.file(passwordFilePath).writeAsString(password); + } + + Future> readMetadata(String name) async { + _validateName(name); + return _readMetadata(name); + } + + Future writeMetadata(String name, Map metadata) async { + _validateName(name); + await initialize(); + await _writeMetadata(name, metadata); + } + + Future _writeMetadata(String name, Map? metadata) async { + final metadataPath = _metadataPath(name); + final file = fs.file(metadataPath); + if (metadata == null || metadata.isEmpty) { + if (await file.exists()) await file.delete(); + return; + } + final normalized = metadata.map( + (key, value) => MapEntry(key.toString(), value), + ); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(normalized), + ); + } + + Future> _readMetadata(String name) async { + final metadataFile = fs.file(_metadataPath(name)); + if (!await metadataFile.exists()) return {}; + final raw = await metadataFile.readAsString(); + if (raw.trim().isEmpty) return {}; + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw ArgumentError('Metadata for "$name" is malformed.'); + } + return Map.from( + decoded.map((key, value) => MapEntry(key.toString(), value)), + ); + } + + Future _readWithPassword( + String name, { + required String password, + }) async { + final secretFile = fs.file(_secretPath(name)); + if (!await secretFile.exists()) return null; + + final payloadText = await secretFile.readAsString(); + final payload = jsonDecode(payloadText); + if (payload is! Map) { + throw ArgumentError('Secret "$name" is malformed.'); + } + final value = VaultCrypto.decryptFromEnvelope( + Map.from(payload), + password: password, + ); + final metadata = await _readMetadata(name); + return VaultSecretRecord(name: name, value: value, metadata: metadata); + } + + Future _writeWithPassword( + String name, { + required String value, + required String password, + Map? metadata, + }) async { + final envelope = VaultCrypto.encryptToEnvelope( + value, + password: password, + iterations: iterations, + ); + await fs.file(_secretPath(name)).writeAsString( + const JsonEncoder.withIndent(' ').convert(envelope), + ); + await _writeMetadata(name, metadata); + } + + String _secretPath(String name) => p.join(storageDir, '$name$_secretSuffix'); + String _metadataPath(String name) => p.join(storageDir, '$name$_metadataSuffix'); + + void _validateName(String name) { + if (!_namePattern.hasMatch(name)) { + throw ArgumentError( + 'Invalid secret name "$name". ' + 'Use only letters, numbers, underscore, dash, and dot.', + ); + } + } +} diff --git a/packages/vault/test/dew_vault_test.dart b/packages/vault/test/dew_vault_test.dart new file mode 100644 index 0000000..ab995b5 --- /dev/null +++ b/packages/vault/test/dew_vault_test.dart @@ -0,0 +1,384 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dew_core/dew_core.dart'; +import 'package:dew_vault/dew_vault.dart'; +import 'package:file/memory.dart'; +import 'package:test/test.dart'; + +const _testConfig = ''' +dew: + vault: + password_file: .project/secrets/dew.vault.password + storage_dir: .project/vault + generators: + postgres_password: + type: random_password + description: Generate a PostgreSQL-like password. + config: + length: 16 + include_symbols: false +'''; + +MemoryFileSystem _makeFs() { + final fs = MemoryFileSystem(); + fs.directory('/.project').createSync(recursive: true); + fs.file('/.project/dew.yaml').writeAsStringSync(_testConfig); + return fs; +} + +Map _mcpToolsForFs(MemoryFileSystem fs) { + final registry = CommandRegistry(); + registerCommands(registry, fs: fs); + return {for (final tool in registry.mcpTools) tool.name: tool}; +} + +Map _decodeToolJson(String output) { + return jsonDecode(output) as Map; +} + +void main() { + group('registerCommands', () { + test('registers vault command and MCP tools', () { + final registry = CommandRegistry(); + final fs = _makeFs(); + registerCommands(registry, fs: fs); + + expect(registry.commands.map((c) => c.name), contains('vault')); + final tools = registry.mcpTools.map((t) => t.name).toSet(); + expect( + tools, + containsAll( + { + 'vault_init', + 'vault_set_secret', + 'vault_get_secret', + 'vault_update_secret', + 'vault_rename_secret', + 'vault_rotate_secret', + 'vault_generate_secret', + 'vault_list_secrets', + 'vault_delete_secret', + }, + ), + ); + }); + }); + + group('Vault command tools', () { + late Map tools; + late MemoryFileSystem fs; + + setUp(() { + fs = _makeFs(); + tools = _mcpToolsForFs(fs); + fs.file('/seed.txt').writeAsStringSync('super-secret'); + fs.file('/new-seed.txt').writeAsStringSync('super-secret-v2'); + }); + + test('set/get/update/list and delete work end-to-end', () async { + await tools['vault_set_secret']!.handler({ + 'name': 'API_KEY', + 'file': '/seed.txt', + 'metadata': + '{"rotation":{"generator":"postgres_password","length":12},"notes":"initial"}', + }); + + final listResult = _decodeToolJson( + await tools['vault_list_secrets']!.handler({'format': 'json'}), + ); + expect(listResult['count'], 1); + expect((listResult['secrets'] as List), contains('API_KEY')); + + final before = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'API_KEY', + 'format': 'json', + }), + ); + expect(before['value'], 'super-secret'); + expect(before['metadata']['rotation']['generator'], 'postgres_password'); + + await tools['vault_update_secret']!.handler({ + 'name': 'API_KEY', + 'metadata': + '{"notes":"rotating","rotation":{"service":"payments","length":20}}', + }); + final merged = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'API_KEY', + 'format': 'json', + }), + ); + expect(merged['metadata']['notes'], 'rotating'); + expect(merged['metadata']['rotation']['service'], 'payments'); + expect(merged['metadata']['rotation']['length'], 20); + + await tools['vault_update_secret']!.handler({ + 'name': 'API_KEY', + 'file': '/new-seed.txt', + }); + final valueUpdated = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'API_KEY', + 'format': 'json', + }), + ); + expect(valueUpdated['value'], 'super-secret-v2'); + + await tools['vault_delete_secret']!.handler({'name': 'API_KEY'}); + final listAfterDelete = _decodeToolJson( + await tools['vault_list_secrets']!.handler({'format': 'json'}), + ); + expect(listAfterDelete['count'], 0); + }); + + test('generate command uses configured generators', () async { + final generated = _decodeToolJson( + await tools['vault_generate_secret']!.handler({ + 'generator': 'postgres_password', + 'arg': ['length=20', 'include_symbols=false'], + 'format': 'json', + }), + ); + expect(generated['generator'], 'postgres_password'); + expect((generated['value'] as String).length, 20); + }); + + test('init command creates directories and password file', () async { + final initResult = _decodeToolJson( + await tools['vault_init']!.handler({'format': 'json'}), + ); + expect(initResult['message'], 'Vault initialised.'); + expect(initResult['initialized'], isTrue); + expect(initResult['password_file'], '/.project/secrets/dew.vault.password'); + expect(initResult['storage_dir'], '/.project/vault'); + + expect(await fs.directory('/.project/vault').exists(), isTrue); + expect(await fs.file('/.project/secrets/dew.vault.password').exists(), isTrue); + }); + + test('init command accepts custom password and storage paths', () async { + final customInit = _decodeToolJson( + await tools['vault_init']!.handler({ + 'password-file': '.custom/secrets/custom.vault.password', + 'storage-dir': '.custom/vault-store', + 'format': 'json', + }), + ); + expect( + customInit['password_file'], + '/.custom/secrets/custom.vault.password', + ); + expect( + customInit['storage_dir'], + '/.custom/vault-store', + ); + expect(await fs.directory('/.custom/vault-store').exists(), isTrue); + expect(await fs.file('/.custom/secrets/custom.vault.password').exists(), isTrue); + }); + + test('set command accepts metadata from file', () async { + fs.file('/meta.json').writeAsStringSync( + '{"rotation":{"generator":"postgres_password","length":16},"notes":"from-file"}', + ); + await tools['vault_set_secret']!.handler({ + 'name': 'META_FILE_SECRET', + 'file': '/seed.txt', + 'metadata-file': '/meta.json', + }); + + final loaded = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'META_FILE_SECRET', + 'format': 'json', + }), + ); + expect(loaded['metadata']['notes'], 'from-file'); + expect(loaded['metadata']['rotation']['length'], 16); + }); + + test('set command accepts --env source', () async { + final envValue = Platform.environment['PATH']; + if (envValue == null || envValue.isEmpty) { + fail('Expected PATH to be set in test environment.'); + } + + await tools['vault_set_secret']!.handler({ + 'name': 'ENV_SECRET', + 'env': 'PATH', + }); + final loaded = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'ENV_SECRET', + 'format': 'json', + }), + ); + expect(loaded['value'], envValue); + }); + + test('update command accepts --env source', () async { + await tools['vault_set_secret']!.handler({ + 'name': 'UPDATABLE_SECRET', + 'file': '/seed.txt', + }); + + final envValue = Platform.environment['HOME']; + if (envValue == null || envValue.isEmpty) { + fail('Expected HOME to be set in test environment.'); + } + + await tools['vault_update_secret']!.handler({ + 'name': 'UPDATABLE_SECRET', + 'env': 'HOME', + }); + final loaded = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'UPDATABLE_SECRET', + 'format': 'json', + }), + ); + expect(loaded['value'], envValue); + }); + + test('update command accepts metadata file', () async { + fs.file('/meta-update.json').writeAsStringSync( + '{"rotation":{"generator":"postgres_password","length":24},"notes":"file-metadata"}', + ); + await tools['vault_set_secret']!.handler({ + 'name': 'META_UPDATE_SECRET', + 'file': '/seed.txt', + }); + await tools['vault_update_secret']!.handler({ + 'name': 'META_UPDATE_SECRET', + 'metadata-file': '/meta-update.json', + }); + + final loaded = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'META_UPDATE_SECRET', + 'format': 'json', + }), + ); + expect(loaded['metadata']['notes'], 'file-metadata'); + expect(loaded['metadata']['rotation']['length'], 24); + }); + + test('rename command moves secret and metadata', () async { + await tools['vault_set_secret']!.handler({ + 'name': 'LEGACY_KEY', + 'file': '/seed.txt', + 'metadata': + '{"rotation":{"generator":"postgres_password","length":16},"notes":"legacy"}', + }); + + final renamed = _decodeToolJson( + await tools['vault_rename_secret']!.handler({ + 'from': 'LEGACY_KEY', + 'to': 'CURRENT_KEY', + 'format': 'json', + }), + ); + expect(renamed['from'], 'LEGACY_KEY'); + expect(renamed['to'], 'CURRENT_KEY'); + + final listResult = _decodeToolJson( + await tools['vault_list_secrets']!.handler({'format': 'json'}), + ); + expect(listResult['secrets'], contains('CURRENT_KEY')); + expect(listResult['secrets'], isNot(contains('LEGACY_KEY'))); + + await expectLater( + tools['vault_get_secret']!.handler({'name': 'LEGACY_KEY', 'format': 'json'}), + throwsA(isA()), + ); + + final newSecret = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'CURRENT_KEY', + 'format': 'json', + }), + ); + expect(newSecret['metadata']['notes'], 'legacy'); + }); + + test('delete command supports json format output', () async { + await tools['vault_set_secret']!.handler({ + 'name': 'TO_DELETE', + 'file': '/seed.txt', + }); + final deleted = _decodeToolJson( + await tools['vault_delete_secret']!.handler({ + 'name': 'TO_DELETE', + 'format': 'json', + }), + ); + expect(deleted, isA>()); + await expectLater( + tools['vault_get_secret']!.handler({'name': 'TO_DELETE'}), + throwsA(isA()), + ); + }); + + test('rotate command uses rotation metadata and can rotate vault password', () async { + await tools['vault_set_secret']!.handler({ + 'name': 'service_db_password', + 'file': '/seed.txt', + 'metadata': + '{"rotation":{"generator":"postgres_password","length":12}}', + }); + await tools['vault_set_secret']!.handler({ + 'name': 'service_api_key', + 'file': '/new-seed.txt', + 'metadata': + '{"rotation":{"generator":"postgres_password","length":12}}', + }); + + final before = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'service_db_password', + 'format': 'json', + }), + ); + expect(before['value'], 'super-secret'); + + final secretRotated = _decodeToolJson( + await tools['vault_rotate_secret']!.handler({ + 'name': 'service_db_password', + 'format': 'json', + }), + ); + expect(secretRotated['scope'], 'secret'); + expect(secretRotated['name'], 'service_db_password'); + + final after = _decodeToolJson( + await tools['vault_get_secret']!.handler({ + 'name': 'service_db_password', + 'format': 'json', + }), + ); + expect(after['value'], isNot('super-secret')); + + final allRotated = _decodeToolJson( + await tools['vault_rotate_secret']!.handler({'format': 'json'}), + ); + expect(allRotated['scope'], 'vault'); + expect(allRotated['rotated_count'], 2); + }); + }); + + group('vault crypto helpers', () { + test('VaultCrypto is reversible and rejects wrong password', () { + const password = 's3cret-pass'; + const value = 'very-sensitive'; + final encoded = VaultCrypto.encryptToEnvelope(value, password: password); + expect(encoded['version'], 1); + final decoded = VaultCrypto.decryptFromEnvelope(encoded, password: password); + expect(decoded, value); + expect( + () => VaultCrypto.decryptFromEnvelope(encoded, password: 'bad-password'), + throwsA(isA()), + ); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index c1bba20..47bd6bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -353,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: