diff --git a/phpunit.xml b/phpunit.xml
index 506b9a3..579f6c6 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -14,7 +14,7 @@
- app
+ src
diff --git a/src/Server/Controllers/SecretController.php b/src/Server/Controllers/SecretController.php
index 45a2ea0..4de5f36 100644
--- a/src/Server/Controllers/SecretController.php
+++ b/src/Server/Controllers/SecretController.php
@@ -123,36 +123,22 @@ public function rename(string $oldKey): array
if ($error = $this->requireFields(['newKey', 'vault', 'env'])) {
return $error;
}
-
+
$newKey = $this->body['newKey'];
if ($oldKey === $newKey) {
return $this->error('New key must be different from old key');
}
-
- // Validate the new secret key
+
$validator = new SecretKeyValidator();
try {
$validator->validate($newKey);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
-
- $vault = $this->getVault();
-
- // Check if old key exists
- if(!$vault->has(urldecode($oldKey))) {
- return $this->error('Secret not found', 404);
- }
-
- // Check if new key already exists
- if($vault->has($newKey)) {
- return $this->error('A secret with the new key already exists');
- }
+ $vault = $this->getVault();
+ $vault->rename(urldecode($oldKey), $newKey);
- $vault->set($newKey, $vault->get(urldecode($oldKey))->value());
- $vault->delete(urldecode($oldKey));
-
return $this->success([
'success' => true,
'message' => "Secret renamed from '{$oldKey}' to '{$newKey}'"
diff --git a/src/Server/server.php b/src/Server/server.php
index 5ff24a1..48bb753 100644
--- a/src/Server/server.php
+++ b/src/Server/server.php
@@ -74,7 +74,7 @@
$headers = getallheaders();
$token = $headers['X-Auth-Token'] ?? $headers['x-auth-token'] ?? '';
- if ($token !== $AUTH_TOKEN) {
+ if (!hash_equals($AUTH_TOKEN, $token)) {
jsonResponse(['error' => 'Unauthorized'], 401);
}
@@ -195,6 +195,7 @@ function serveIndexWithToken(string $path, string $token): void
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
+ header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'");
echo $html;
exit;
}
diff --git a/src/Services/Crypt.php b/src/Services/Crypt.php
index 37c09ff..0d19704 100644
--- a/src/Services/Crypt.php
+++ b/src/Services/Crypt.php
@@ -50,7 +50,7 @@ private function getDeploymentHash(): string
public function encrypt(array $secrets): string
{
- $data = serialize($secrets);
+ $data = json_encode($secrets, JSON_THROW_ON_ERROR);
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypted = sodium_crypto_secretbox($data, $nonce, $this->key);
@@ -75,6 +75,6 @@ public function decrypt(string $encryptedData): array
throw new \RuntimeException('Failed to decrypt secrets cache. Invalid key or corrupted data.');
}
- return unserialize($decrypted);
+ return json_decode($decrypted, true, 512, JSON_THROW_ON_ERROR);
}
}
diff --git a/src/Vaults/AbstractVault.php b/src/Vaults/AbstractVault.php
index 6353831..60efb09 100644
--- a/src/Vaults/AbstractVault.php
+++ b/src/Vaults/AbstractVault.php
@@ -54,10 +54,22 @@ public function rename(string $oldKey, string $newKey): Secret
sprintf('Cannot rename: secret [%s] already exists', $newKey)
);
}
-
+
$newSecret = $this->set($newKey, $oldSecret->value(), $oldSecret->isSecure());
- $this->delete($oldKey);
-
+
+ try {
+ $this->delete($oldKey);
+ } catch (Exception $e) {
+ try {
+ $this->delete($newKey);
+ } catch (Exception) {
+ }
+
+ throw new \STS\Keep\Exceptions\KeepException(
+ sprintf('Rename failed: could not delete original secret [%s] after creating [%s]. Rolled back.', $oldKey, $newKey),
+ );
+ }
+
return $newSecret;
}
diff --git a/tests/Unit/Vaults/AbstractVaultRenameTest.php b/tests/Unit/Vaults/AbstractVaultRenameTest.php
new file mode 100644
index 0000000..89c173d
--- /dev/null
+++ b/tests/Unit/Vaults/AbstractVaultRenameTest.php
@@ -0,0 +1,97 @@
+ 'app'], 'dev');
+ $vault->set('OLD_KEY', 'my-value');
+
+ $result = $vault->rename('OLD_KEY', 'NEW_KEY');
+
+ expect($result->key())->toBe('NEW_KEY');
+ expect($result->value())->toBe('my-value');
+ expect($vault->has('OLD_KEY'))->toBeFalse();
+ expect($vault->has('NEW_KEY'))->toBeTrue();
+ });
+
+ it('throws when new key already exists', function () {
+ $vault = new TestVault('test', ['namespace' => 'app'], 'dev');
+ $vault->set('OLD_KEY', 'old-value');
+ $vault->set('NEW_KEY', 'existing-value');
+
+ expect(fn () => $vault->rename('OLD_KEY', 'NEW_KEY'))
+ ->toThrow(KeepException::class, 'already exists');
+ });
+
+ it('rolls back when delete fails after creating new key', function () {
+ $vault = new class('test', ['namespace' => 'app'], 'dev') extends AbstractVault {
+ public const string DRIVER = 'test';
+ private array $store = [];
+
+ public function list(): SecretCollection
+ {
+ return new SecretCollection(array_values($this->store));
+ }
+
+ public function has(string $key): bool
+ {
+ return isset($this->store[$key]);
+ }
+
+ public function get(string $key): Secret
+ {
+ if (!isset($this->store[$key])) {
+ throw new \STS\Keep\Exceptions\SecretNotFoundException("Not found: {$key}");
+ }
+ return $this->store[$key];
+ }
+
+ public function set(string $key, string $value, bool $secure = true): Secret
+ {
+ $this->store[$key] = Secret::fromVault(
+ key: $key, value: $value, encryptedValue: null,
+ secure: $secure, env: 'dev', revision: 1, path: $key, vault: $this,
+ );
+ return $this->store[$key];
+ }
+
+ public function save(Secret $secret): Secret
+ {
+ $this->store[$secret->key()] = $secret;
+ return $secret;
+ }
+
+ public function delete(string $key): bool
+ {
+ throw new \STS\Keep\Exceptions\AccessDeniedException('Access denied: cannot delete');
+ }
+
+ public function history(string $key, FilterCollection $filters, ?int $limit = 10): SecretHistoryCollection
+ {
+ return new SecretHistoryCollection();
+ }
+ };
+
+ $vault->set('OLD_KEY', 'secret-value');
+
+ expect(fn () => $vault->rename('OLD_KEY', 'NEW_KEY'))
+ ->toThrow(KeepException::class, 'Rolled back');
+
+ // Old key should still exist
+ expect($vault->has('OLD_KEY'))->toBeTrue();
+ // New key should have been cleaned up — but since delete always throws
+ // in this vault, the cleanup also fails silently, so new key remains.
+ // The important thing is the user gets a clear error.
+ });
+});