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 Secret Manager
|
||||||
|
|
||||||
Dew Vault is a secret manager for your projects. It helps you keep secrets out of
|
Dew Vault stores project secrets as encrypted files under `.project/vault`.
|
||||||
version control by storing each secret as an encrypted file under `.project/vault`.
|
|
||||||
By default the vault password is stored in `.project/secrets/dew.vault.password`.
|
By default the vault password is stored in `.project/secrets/dew.vault.password`.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
@ -31,9 +30,9 @@ dew:
|
||||||
description: Generate stable-looking unique IDs.
|
description: Generate stable-looking unique IDs.
|
||||||
```
|
```
|
||||||
|
|
||||||
`generators` maps a generator name (for example `postgres_password`) to a built-in
|
`generators` maps a generator name to a built-in generator definition. Values
|
||||||
generator definition. Values under `config` are defaults and can be overridden per
|
under `config` are defaults and can be overridden per command invocation or in
|
||||||
run or stored in secret rotation metadata.
|
secret rotation metadata.
|
||||||
|
|
||||||
Built-in generator types are resolved inside Dew, so secrets can be generated
|
Built-in generator types are resolved inside Dew, so secrets can be generated
|
||||||
without depending on host binaries.
|
without depending on host binaries.
|
||||||
|
|
@ -45,18 +44,16 @@ machine-friendly automation.
|
||||||
|
|
||||||
### Initialize Vault
|
### Initialize Vault
|
||||||
|
|
||||||
Initialize the vault storage and metadata.
|
Initialize vault directories and password file.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault init
|
dew vault init
|
||||||
|
|
||||||
dew vault init --password-file .project/secrets/dew.vault.password
|
dew vault init --password-file .project/secrets/dew.vault.password
|
||||||
|
dew vault init --storage-dir .project/vault
|
||||||
```
|
```
|
||||||
|
|
||||||
### List all secrets
|
### List all secrets
|
||||||
|
|
||||||
List stored secrets.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault list
|
dew vault list
|
||||||
dew vault list --format json
|
dew vault list --format json
|
||||||
|
|
@ -67,23 +64,15 @@ dew vault list --format json
|
||||||
`set` stores or replaces a secret and optional metadata.
|
`set` stores or replaces a secret and optional metadata.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault set <secret-name> # Prompts for secret value
|
dew vault set --name DB_PASSWORD --file /path/to/secret.txt
|
||||||
dew vault set <secret-name> --env ENV_VAR_NAME # Uses value from environment variable
|
dew vault set --name DB_PASSWORD --env ENV_VAR_NAME
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get a secret
|
### Get a secret
|
||||||
|
|
||||||
`get` retrieves a secret by name.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault get <secret-name>
|
dew vault get --name DB_PASSWORD
|
||||||
dew vault get <secret-name> --format json
|
dew vault get --name DB_PASSWORD --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update a secret
|
### Update a secret
|
||||||
|
|
@ -92,56 +81,43 @@ dew vault get <secret-name> --format json
|
||||||
metadata only.
|
metadata only.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault update <secret-name> --env ROLLED_PASSWORD
|
dew vault update --name DB_PASSWORD --metadata '{"rotation":{"enabled":true,"generator":"postgres_password","length":64}}'
|
||||||
dew vault update <secret-name> --metadata '{"rotation":{"enabled":false}}'
|
dew vault update --name DB_PASSWORD --metadata-file .project/vault/db_password.meta.json
|
||||||
dew vault update <secret-name> --metadata-file .project/vault/db_password.meta.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rename a secret
|
### Rename a secret
|
||||||
|
|
||||||
`rename` changes a secret identifier while preserving value and metadata.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault rename OLD_NAME NEW_NAME
|
dew vault rename --from OLD_NAME --to NEW_NAME
|
||||||
dew vault rename OLD_NAME NEW_NAME --format json
|
dew vault rename --from OLD_NAME --to NEW_NAME --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate a secret value
|
### 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
|
```bash
|
||||||
dew vault generate postgres_password --length 64 --include_symbols
|
dew vault generate --generator postgres_password --arg length=64 --arg include_symbols=true
|
||||||
dew vault generate jwt_secret --bytes 64 --encoding base64
|
dew vault generate --generator jwt_secret --arg bytes=64 --arg encoding=base64
|
||||||
dew vault generate postgres_password --service payments --username app_user --format json
|
dew vault generate --generator postgres_password --arg service=payments --arg 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rotate secrets
|
### Rotate secrets
|
||||||
|
|
||||||
`rotate` rewraps secrets with a new vault password when run without a name.
|
`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
|
When run with a secret name, it rotates only that secret.
|
||||||
metadata (`rotation.enabled: true` and `rotation.generator`), Dew invokes that
|
|
||||||
configured built-in generator using the provided rotation values.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault rotate
|
dew vault rotate
|
||||||
dew vault rotate <secret-name>
|
dew vault rotate --name DB_PASSWORD
|
||||||
dew vault rotate <secret-name> --format json
|
dew vault rotate --name DB_PASSWORD --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Delete a secret
|
### Delete a secret
|
||||||
|
|
||||||
`delete` removes a secret and metadata from the vault.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dew vault delete <secret-name>
|
dew vault delete --name DB_PASSWORD
|
||||||
dew vault delete <secret-name> --format json
|
dew vault delete --name DB_PASSWORD --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Metadata format for rotation-aware secrets
|
### Metadata format for rotation-aware secrets
|
||||||
|
|
@ -151,7 +127,6 @@ Attach arbitrary metadata and include rotation policy details. Example shape:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rotation": {
|
"rotation": {
|
||||||
"enabled": true,
|
|
||||||
"generator": "postgres_password",
|
"generator": "postgres_password",
|
||||||
"service": "payments",
|
"service": "payments",
|
||||||
"username": "app_user",
|
"username": "app_user",
|
||||||
|
|
@ -164,6 +139,6 @@ Attach arbitrary metadata and include rotation policy details. Example shape:
|
||||||
Rotation flow:
|
Rotation flow:
|
||||||
|
|
||||||
1. Define a built-in generator in `dew.yaml` under `dew.vault.generators`.
|
1. Define a built-in generator in `dew.yaml` under `dew.vault.generators`.
|
||||||
2. Attach `rotation.generator` and generator args to the secret metadata.
|
2. Attach `rotation.generator` and generator args to secret metadata.
|
||||||
3. Run `dew vault rotate <secret-name>` to rotate one secret, or `dew vault rotate`
|
3. Run `dew vault rotate --name ...` to rotate one secret, or `dew vault rotate`
|
||||||
to rotate all configured secrets.
|
to rotate all secrets.
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,46 @@ as MCP tools through `DewToolCommand`.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
This package is currently a scaffold/stub to establish the command and MCP
|
This package implements encrypted secret storage, rotation-aware metadata, and
|
||||||
wiring. It does not yet implement full encrypted secret storage or rotation logic.
|
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
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](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;
|
library;
|
||||||
|
|
||||||
export 'src/dew_vault_base.dart';
|
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:dew_core/dew_core.dart';
|
||||||
import 'package:file/file.dart';
|
import 'package:file/file.dart';
|
||||||
|
|
@ -14,6 +18,6 @@ void registerCommands(
|
||||||
CommandRegistry registry, {
|
CommandRegistry registry, {
|
||||||
FileSystem fs = const LocalFileSystem(),
|
FileSystem fs = const LocalFileSystem(),
|
||||||
}) {
|
}) {
|
||||||
registry.register(VaultCommand());
|
registry.register(VaultCommand(fs: fs));
|
||||||
registry.registerInitHook(VaultInitHook(fs: fs));
|
registry.registerInitHook(VaultInitHook(fs: fs));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
String renderVaultOutput({
|
String renderVaultOutput({
|
||||||
String format = 'default',
|
String format = 'default',
|
||||||
|
|
@ -28,3 +32,131 @@ String formatFromArgs(Map<String, dynamic> args) {
|
||||||
if (value == null || value.toString().trim().isEmpty) return 'default';
|
if (value == null || value.toString().trim().isEmpty) return 'default';
|
||||||
return value.toString();
|
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: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';
|
import '../command_output.dart';
|
||||||
|
|
||||||
class DeleteCommand extends DewCommand with DewToolCommand {
|
class DeleteCommand extends DewCommand with DewToolCommand {
|
||||||
DeleteCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'name',
|
'name',
|
||||||
|
|
@ -32,9 +38,18 @@ class DeleteCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final secretName = requireStringArg(args, 'name');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Delete stub executed.',
|
message: 'Deleted.',
|
||||||
json: {'secret': secretName},
|
json: {'secret': secretName},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
import '../command_output.dart';
|
import '../command_output.dart';
|
||||||
|
import '../vault_config.dart';
|
||||||
|
import '../vault_generators.dart';
|
||||||
|
|
||||||
class GenerateCommand extends DewCommand with DewToolCommand {
|
class GenerateCommand extends DewCommand with DewToolCommand {
|
||||||
GenerateCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
GenerateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'generator',
|
'generator',
|
||||||
|
|
@ -36,14 +42,23 @@ class GenerateCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final generator = requireStringArg(args, 'generator');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Generate stub output.',
|
message: value,
|
||||||
json: {
|
json: {
|
||||||
'generator': generator,
|
'generator': generator,
|
||||||
'options': overrides == null ? const <String>[] : overrides,
|
'options': rawOverrides,
|
||||||
'value': '<generated>',
|
'value': value,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
import '../command_output.dart';
|
import '../command_output.dart';
|
||||||
|
import '../vault_config.dart';
|
||||||
|
import '../vault_store.dart';
|
||||||
|
|
||||||
class GetCommand extends DewCommand with DewToolCommand {
|
class GetCommand extends DewCommand with DewToolCommand {
|
||||||
GetCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('name', abbr: 'n', mandatory: true, help: 'Secret name.')
|
..addOption('name', abbr: 'n', mandatory: true, help: 'Secret name.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
@ -27,11 +33,28 @@ class GetCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final secretName = requireStringArg(args, 'name');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Get stub value: [redacted].',
|
message: record.value,
|
||||||
json: {'secret': secretName},
|
json: {
|
||||||
|
'name': record.name,
|
||||||
|
'value': record.value,
|
||||||
|
'metadata': record.metadata,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
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';
|
import '../command_output.dart';
|
||||||
|
|
||||||
class VaultInitCommand extends DewCommand with DewToolCommand {
|
class VaultInitCommand extends DewCommand with DewToolCommand {
|
||||||
VaultInitCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
VaultInitCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'password-file',
|
'password-file',
|
||||||
|
|
@ -36,14 +41,23 @@ class VaultInitCommand extends DewCommand with DewToolCommand {
|
||||||
@override
|
@override
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final passwordFile = requireStringArg(args, 'password-file');
|
final passwordFile = args['password-file']?.toString() ?? '.project/secrets/dew.vault.password';
|
||||||
final storageDir = requireStringArg(args, 'storage-dir');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Vault init stub completed.',
|
message: 'Vault initialised.',
|
||||||
json: {
|
json: {
|
||||||
'password_file': passwordFile,
|
'password_file': store.passwordFilePath,
|
||||||
'storage_dir': storageDir,
|
'storage_dir': store.storageDir,
|
||||||
'initialized': true,
|
'initialized': true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
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';
|
import '../command_output.dart';
|
||||||
|
|
||||||
class ListCommand extends DewCommand with DewToolCommand {
|
class ListCommand extends DewCommand with DewToolCommand {
|
||||||
ListCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'format',
|
'format',
|
||||||
defaultsTo: 'default',
|
defaultsTo: 'default',
|
||||||
|
|
@ -24,10 +30,30 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
@override
|
@override
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'No secrets found (stubbed vault).',
|
message: output,
|
||||||
json: {'secrets': <String>[], 'count': 0},
|
json: {
|
||||||
|
'secrets': names,
|
||||||
|
'count': names.length,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
import '../command_output.dart';
|
import '../command_output.dart';
|
||||||
|
import '../vault_config.dart';
|
||||||
|
import '../vault_store.dart';
|
||||||
|
|
||||||
class RenameCommand extends DewCommand with DewToolCommand {
|
class RenameCommand extends DewCommand with DewToolCommand {
|
||||||
RenameCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
RenameCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'from',
|
'from',
|
||||||
|
|
@ -37,9 +43,18 @@ class RenameCommand extends DewCommand with DewToolCommand {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final from = requireStringArg(args, 'from');
|
final from = requireStringArg(args, 'from');
|
||||||
final to = requireStringArg(args, 'to');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Rename stub executed.',
|
message: 'Renamed.',
|
||||||
json: {'from': from, 'to': to},
|
json: {'from': from, 'to': to},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
import '../command_output.dart';
|
import '../command_output.dart';
|
||||||
|
import '../vault_config.dart';
|
||||||
|
import '../vault_generators.dart';
|
||||||
|
import '../vault_store.dart';
|
||||||
|
|
||||||
class RotateCommand extends DewCommand with DewToolCommand {
|
class RotateCommand extends DewCommand with DewToolCommand {
|
||||||
RotateCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
RotateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('name', help: 'Secret name to rotate; omit to rotate vault password.')
|
..addOption('name', help: 'Secret name to rotate; omit to rotate vault password.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
@ -19,7 +26,7 @@ class RotateCommand extends DewCommand with DewToolCommand {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description =
|
final String description =
|
||||||
'Rotate vault password or a single secret value (stub).';
|
'Rotate vault password or a single secret value.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String toolName = 'vault_rotate_secret';
|
final String toolName = 'vault_rotate_secret';
|
||||||
|
|
@ -28,11 +35,74 @@ class RotateCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final target = args['name']?.toString();
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Rotate stub executed.',
|
message: 'Secret rotated.',
|
||||||
json: {'target': target ?? '<all>', 'scope': target == null ? 'vault' : 'secret'},
|
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:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
import '../command_output.dart';
|
import '../command_output.dart';
|
||||||
|
import '../vault_config.dart';
|
||||||
|
import '../vault_store.dart';
|
||||||
|
|
||||||
class SetCommand extends DewCommand with DewToolCommand {
|
class SetCommand extends DewCommand with DewToolCommand {
|
||||||
SetCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
SetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'name',
|
'name',
|
||||||
|
|
@ -36,17 +42,37 @@ class SetCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final secretName = requireStringArg(args, 'name');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Set stub executed.',
|
message: 'Stored secret.',
|
||||||
json: {
|
json: {
|
||||||
'secret': secretName,
|
'name': secretName,
|
||||||
'source': source == null ? 'interactive' : source.toString(),
|
'metadata': metadata,
|
||||||
'status': 'set',
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
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';
|
import '../command_output.dart';
|
||||||
|
|
||||||
class UpdateCommand extends DewCommand with DewToolCommand {
|
class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
UpdateCommand() {
|
final FileSystem _fs;
|
||||||
|
|
||||||
|
UpdateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'name',
|
'name',
|
||||||
|
|
@ -36,13 +42,60 @@ class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final format = formatFromArgs(args);
|
final format = formatFromArgs(args);
|
||||||
final secretName = requireStringArg(args, 'name');
|
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(
|
return renderVaultOutput(
|
||||||
format: format,
|
format: format,
|
||||||
message: 'Update stub executed.',
|
message: 'Secret updated.',
|
||||||
json: {
|
json: {
|
||||||
'secret': secretName,
|
'name': secretName,
|
||||||
'mode': mode,
|
'updated_metadata': hasMetadataSource,
|
||||||
|
'updated_value': hasValueSource,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,21 @@ import 'commands/rename_command.dart';
|
||||||
import 'commands/rotate_command.dart';
|
import 'commands/rotate_command.dart';
|
||||||
import 'commands/set_command.dart';
|
import 'commands/set_command.dart';
|
||||||
import 'commands/update_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.
|
/// Top-level CLI command for all Vault operations.
|
||||||
class VaultCommand extends DewCommand {
|
class VaultCommand extends DewCommand {
|
||||||
VaultCommand() {
|
VaultCommand({FileSystem fs = const LocalFileSystem()}) {
|
||||||
addSubcommand(VaultInitCommand());
|
addSubcommand(VaultInitCommand(fs: fs));
|
||||||
addSubcommand(ListCommand());
|
addSubcommand(ListCommand(fs: fs));
|
||||||
addSubcommand(SetCommand());
|
addSubcommand(SetCommand(fs: fs));
|
||||||
addSubcommand(GetCommand());
|
addSubcommand(GetCommand(fs: fs));
|
||||||
addSubcommand(UpdateCommand());
|
addSubcommand(UpdateCommand(fs: fs));
|
||||||
addSubcommand(RenameCommand());
|
addSubcommand(RenameCommand(fs: fs));
|
||||||
addSubcommand(RotateCommand());
|
addSubcommand(RotateCommand(fs: fs));
|
||||||
addSubcommand(GenerateCommand());
|
addSubcommand(GenerateCommand(fs: fs));
|
||||||
addSubcommand(DeleteCommand());
|
addSubcommand(DeleteCommand(fs: fs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.6"
|
version: "3.1.6"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue