feat(vault): complete command implementations and testing

This commit is contained in:
Chris Hendrickson 2026-05-03 13:42:04 -04:00
parent 3819f8ad06
commit bd40758bb2
20 changed files with 1569 additions and 103 deletions

View file

@ -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 <secret-name> # Prompts for secret value
dew vault set <secret-name> --env ENV_VAR_NAME # Uses value from environment variable
dew vault set <secret-name> --file /path/to/secret.txt # Uses value from file
echo "secret value" | dew vault set <secret-name> # 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 <secret-name>
dew vault get <secret-name> --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 <secret-name> --format json
metadata only.
```bash
dew vault update <secret-name> --env ROLLED_PASSWORD
dew vault update <secret-name> --metadata '{"rotation":{"enabled":false}}'
dew vault update <secret-name> --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 <secret-name>
dew vault rotate <secret-name> --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 <secret-name>
dew vault delete <secret-name> --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 <secret-name>` 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.

View file

@ -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 <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 <command> --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`.

View file

@ -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));
}

View file

@ -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<String, dynamic> 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<String?> readSecretInput(
Map<String, dynamic> 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<String, dynamic> mergeMetadata(
Map<String, dynamic> base,
Map<String, dynamic>? updates,
) {
final merged = Map<String, dynamic>.from(base);
if (updates == null) return merged;
for (final entry in updates.entries) {
merged[entry.key] = entry.value;
}
return merged;
}
Future<Map<String, dynamic>> parseMetadataFromArgs({
required Map<String, dynamic> 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<String, dynamic>.fromEntries(
decoded.entries.map((e) => MapEntry(e.key.toString(), e.value)),
);
}
Map<String, dynamic> parseGeneratorOptionPairs(dynamic value) {
final values = <String, dynamic>{};
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;
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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},
);
}

View file

@ -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<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final generator = requireStringArg(args, 'generator');
final overrides = args['arg'] as List<dynamic>?;
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 <String>[] : overrides,
'value': '<generated>',
'options': rawOverrides,
'value': value,
},
);
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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,
},
);
}
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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,
},
);

View file

@ -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<String> callAsTool(Map<String, dynamic> 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 <String>[], 'count': 0},
);
}
final output = names.join('\n');
return renderVaultOutput(
format: format,
message: 'No secrets found (stubbed vault).',
json: {'secrets': <String>[], 'count': 0},
message: output,
json: {
'secrets': names,
'count': names.length,
},
);
}

View file

@ -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},
);
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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 ?? '<all>', '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<String, dynamic>.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', {});
}
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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,
},
);
}
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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<String, dynamic> 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,
},
);
}

View file

@ -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

View file

@ -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<String, dynamic> config;
const VaultGeneratorDefinition({
required this.type,
this.description,
required this.config,
});
}
class VaultConfig {
final String passwordFile;
final String storageDir;
final Map<String, VaultGeneratorDefinition> 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 = <String, VaultGeneratorDefinition>{};
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<String, dynamic> _coerceMap(dynamic input) {
if (input == null) return {};
if (input is Map) {
return input.map((key, value) => MapEntry(key.toString(), _coerceValue(value)));
}
return {};
}
Map<String, dynamic> _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<String, dynamic> _asMap(dynamic value) => _coerceMapOfDynamic(value);

View file

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> _asMap(dynamic value) {
if (value is Map) return Map<String, dynamic>.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));
}
}

View file

@ -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<String, dynamic> 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<String, VaultGeneratorDefinition> generators,
Map<String, dynamic>? options,
}) {
final configured = generators[nameOrType];
if (configured != null) {
final merged = Map<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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)),
);
}
}

View file

@ -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<String, dynamic> 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<void> initialize() async {
await fs.directory(storageDir).create(recursive: true);
await fs.directory(p.dirname(passwordFilePath)).create(recursive: true);
await ensurePassword();
}
Future<bool> exists(String name) async {
_validateName(name);
return fs.file(_secretPath(name)).exists();
}
Future<VaultSecretRecord?> 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<String, dynamic>.from(payload),
password: password,
);
final metadata = await _readMetadata(name);
return VaultSecretRecord(name: name, value: value, metadata: metadata);
}
Future<void> write(
String name,
String value, {
Map<String, dynamic>? 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<void> 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<void> 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<int> rotateVaultPassword() async {
final names = await listSecretNames();
if (names.isEmpty) {
return 0;
}
final password = await readPassword();
final records = <VaultSecretRecord>[];
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<List<String>> listSecretNames() async {
final dir = fs.directory(storageDir);
if (!await dir.exists()) return const [];
final names = <String>[];
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<String> 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<String> 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<void> writePassword(String password) async {
await fs.directory(p.dirname(passwordFilePath)).create(recursive: true);
await fs.file(passwordFilePath).writeAsString(password);
}
Future<Map<String, dynamic>> readMetadata(String name) async {
_validateName(name);
return _readMetadata(name);
}
Future<void> writeMetadata(String name, Map<String, dynamic> metadata) async {
_validateName(name);
await initialize();
await _writeMetadata(name, metadata);
}
Future<void> _writeMetadata(String name, Map<String, dynamic>? 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<Map<String, dynamic>> _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<String, dynamic>.from(
decoded.map((key, value) => MapEntry(key.toString(), value)),
);
}
Future<VaultSecretRecord?> _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<String, dynamic>.from(payload),
password: password,
);
final metadata = await _readMetadata(name);
return VaultSecretRecord(name: name, value: value, metadata: metadata);
}
Future<void> _writeWithPassword(
String name, {
required String value,
required String password,
Map<String, dynamic>? 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.',
);
}
}
}

View file

@ -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<String, McpTool> _mcpToolsForFs(MemoryFileSystem fs) {
final registry = CommandRegistry();
registerCommands(registry, fs: fs);
return {for (final tool in registry.mcpTools) tool.name: tool};
}
Map<String, dynamic> _decodeToolJson(String output) {
return jsonDecode(output) as Map<String, dynamic>;
}
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<String, McpTool> 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<ArgumentError>()),
);
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<Map<String, dynamic>>());
await expectLater(
tools['vault_get_secret']!.handler({'name': 'TO_DELETE'}),
throwsA(isA<ArgumentError>()),
);
});
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<ArgumentError>()),
);
});
});
}

View file

@ -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: