feat(vault): complete command implementations and testing
This commit is contained in:
parent
3819f8ad06
commit
bd40758bb2
20 changed files with 1569 additions and 103 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
return renderVaultOutput(
|
||||
format: format,
|
||||
message: 'Get stub value: [redacted].',
|
||||
json: {'secret': secretName},
|
||||
|
||||
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: record.value,
|
||||
json: {
|
||||
'name': record.name,
|
||||
'value': record.value,
|
||||
'metadata': record.metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (stubbed vault).',
|
||||
json: {'secrets': <String>[], 'count': 0},
|
||||
message: 'No secrets found.',
|
||||
json: {'secrets': const <String>[], 'count': 0},
|
||||
);
|
||||
}
|
||||
|
||||
final output = names.join('\n');
|
||||
return renderVaultOutput(
|
||||
format: format,
|
||||
message: output,
|
||||
json: {
|
||||
'secrets': names,
|
||||
'count': names.length,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: 'Rotate stub executed.',
|
||||
json: {'target': target ?? '<all>', 'scope': target == null ? 'vault' : 'secret'},
|
||||
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: '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', {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
packages/vault/lib/src/vault_config.dart
Normal file
99
packages/vault/lib/src/vault_config.dart
Normal 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);
|
||||
|
||||
128
packages/vault/lib/src/vault_crypto.dart
Normal file
128
packages/vault/lib/src/vault_crypto.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
142
packages/vault/lib/src/vault_generators.dart
Normal file
142
packages/vault/lib/src/vault_generators.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
298
packages/vault/lib/src/vault_store.dart
Normal file
298
packages/vault/lib/src/vault_store.dart
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
384
packages/vault/test/dew_vault_test.dart
Normal file
384
packages/vault/test/dew_vault_test.dart
Normal 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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue