From 47a06f41a64a1023b8adcb9a3a59f2f5152ddc42 Mon Sep 17 00:00:00 2001 From: Jonathon Byrdziak Date: Sat, 21 Mar 2026 13:28:13 -0700 Subject: [PATCH] Store encryption keys per-project in non-production environments Uses config:env (via IncidentDetector::isDev()) to determine storage location. Production stores globally in protocol's .node/key, all other environments store per-project in .protocol/key. Automatically adds .protocol/ to the project's .gitignore when storing locally. Co-Authored-By: Claude Opus 4.6 --- src/Commands/SecretsSetup.php | 18 ++++++++--- src/Helpers/Secrets.php | 59 ++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/Commands/SecretsSetup.php b/src/Commands/SecretsSetup.php index a027ef1..bdf2dbc 100644 --- a/src/Commands/SecretsSetup.php +++ b/src/Commands/SecretsSetup.php @@ -56,11 +56,15 @@ protected function configure(): void With an argument, stores the provided key on this node. Example: protocol secrets:setup "your-hex-key-here" + The storage location is determined by the environment set via + config:env. Production environments store the key globally in + protocol's .node/key. All other environments (local, dev, staging) + store the key per-project in the current working directory's + .protocol/key. + The key is also read automatically from the PROTOCOL_ENCRYPTION_KEY environment variable, so CI/CD workflows can pass it from GitHub secrets. - The key is stored at ~/.protocol/.node/key with 0600 permissions. - HELP) ; $this @@ -86,8 +90,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + $keyPath = Secrets::keyPath(); + $scope = Secrets::isGlobal() ? 'global' : 'project'; + $output->writeln("Environment scope: {$scope}"); + if (Secrets::hasKey() && !$providedKey) { - $output->writeln('Encryption key already exists at: ' . Secrets::keyPath() . ''); + $output->writeln('Encryption key already exists at: ' . $keyPath . ''); $output->writeln('To replace it, delete the existing key first.'); return Command::SUCCESS; } @@ -101,7 +109,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if (Secrets::storeKey($providedKey)) { - $output->writeln('Encryption key stored at: ' . Secrets::keyPath() . ''); + $output->writeln('Encryption key stored at: ' . $keyPath . ''); } else { $output->writeln('Failed to store encryption key.'); return Command::FAILURE; @@ -117,7 +125,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Copy this key to other nodes:'); $output->writeln(" protocol secrets:setup \"{$hexKey}\""); $output->writeln(''); - $output->writeln('Stored at: ' . Secrets::keyPath()); + $output->writeln('Stored at: ' . $keyPath); } else { $output->writeln('Failed to generate encryption key.'); return Command::FAILURE; diff --git a/src/Helpers/Secrets.php b/src/Helpers/Secrets.php index 8fbd4ae..b0f837c 100644 --- a/src/Helpers/Secrets.php +++ b/src/Helpers/Secrets.php @@ -11,12 +11,33 @@ class Secrets const NONCE_LENGTH = 12; const TAG_LENGTH = 16; + /** + * Determine whether secrets should be stored globally (production) + * or per-project (all other environments). + */ + public static function isGlobal(): bool + { + return !IncidentDetector::isDev(); + } + + /** + * Path to the project-local .protocol directory. + */ + public static function projectDataDir(): string + { + return WORKING_DIR . '.protocol' . DIRECTORY_SEPARATOR; + } + /** * Path to the encryption key file. + * Production stores globally in NODE_DATA_DIR, all other envs store per-project. */ public static function keyPath(): string { - return NODE_DATA_DIR . 'key'; + if (self::isGlobal()) { + return NODE_DATA_DIR . 'key'; + } + return self::projectDataDir() . 'key'; } /** @@ -37,17 +58,24 @@ public static function generateKey(): string /** * Store the encryption key to disk. + * Location is determined by the current environment (config:env). */ public static function storeKey(string $hexKey): bool { - $dir = dirname(self::keyPath()); + $path = self::keyPath(); + $dir = dirname($path); if (!is_dir($dir)) { mkdir($dir, 0700, true); } - $written = file_put_contents(self::keyPath(), $hexKey); + $written = file_put_contents($path, $hexKey); if ($written !== false) { - chmod(self::keyPath(), 0600); + chmod($path, 0600); + } + + if (!self::isGlobal()) { + self::ensureGitignore(); } + return $written !== false; } @@ -167,4 +195,27 @@ public static function decryptToTempFile(string $inputPath): ?string chmod($tmpFile, 0600); return $tmpFile; } + + /** + * Ensure .protocol/ is listed in the project's .gitignore. + */ + public static function ensureGitignore(): void + { + $gitignorePath = WORKING_DIR . '.gitignore'; + $entry = '.protocol/'; + + if (is_file($gitignorePath)) { + $contents = file_get_contents($gitignorePath); + // Check if already present (exact line match) + $lines = array_map('trim', explode("\n", $contents)); + if (in_array($entry, $lines)) { + return; + } + // Append with a newline if file doesn't end with one + $append = (substr($contents, -1) === "\n" ? '' : "\n") . $entry . "\n"; + file_put_contents($gitignorePath, $contents . $append); + } else { + file_put_contents($gitignorePath, $entry . "\n"); + } + } }