Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/Casts/Binary.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ public function set(Model $model, string $key, mixed $value, array $attributes):
}

// Runtime type validation despite PHPDoc string guarantee
// @phpstan-ignore function.alreadyNarrowedType
if (! is_string($value)) {
throw new \RuntimeException("Expected string for {$key} in set(), got: ".gettype($value));
}
Expand Down
100 changes: 100 additions & 0 deletions app/Casts/EncryptedWithDek.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Casts;

use App\Models\TenantKey;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

/**
* Cast for encrypting attributes using the tenant's DEK (Data Encryption Key).
*
* This cast:
* - GET: Decrypts ciphertext using tenant DEK (via TenantKey::decrypt)
* - SET: Encrypts plaintext using tenant DEK (via TenantKey::encrypt)
*
* Storage format in DB: JSON with {ciphertext: base64, nonce: base64}
* This allows proper key rotation via keys:rotate-dek command.
*
* @implements CastsAttributes<string|null, string|null>
*/
class EncryptedWithDek implements CastsAttributes
{
/**
* Cast the given value from storage (decrypt).
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}

if (! is_string($value)) {
throw new \RuntimeException("Expected string value for {$key}, got ".gettype($value));
}

// Decode JSON structure: {ciphertext: base64, nonce: base64}
$data = json_decode($value, true);
if (! is_array($data) || ! isset($data['ciphertext'], $data['nonce'])) {
throw new \RuntimeException("Invalid encrypted data format for {$key}");
}

if (! is_string($data['ciphertext']) || ! is_string($data['nonce'])) {
throw new \RuntimeException("Invalid ciphertext/nonce types for {$key}");
}

$ciphertext = base64_decode($data['ciphertext'], true);
$nonce = base64_decode($data['nonce'], true);

if ($ciphertext === false || $nonce === false) {
throw new \RuntimeException("Failed to decode base64 data for {$key}");
}

// Get tenant and decrypt
/** @var TenantKey $tenant */
$tenant = TenantKey::findOrFail($attributes['tenant_id']);

return $tenant->decrypt($ciphertext, $nonce);
}

/**
* Prepare the given value for storage (encrypt).
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}

if (! is_string($value)) {
throw new \RuntimeException("Expected string value for {$key}, got ".gettype($value));
}

// Get tenant and encrypt
/** @var TenantKey $tenant */
$tenant = TenantKey::findOrFail($attributes['tenant_id']);
$encrypted = $tenant->encrypt($value);

// Store as JSON: {ciphertext: base64, nonce: base64}
$json = json_encode([
'ciphertext' => base64_encode($encrypted['ciphertext']),
'nonce' => base64_encode($encrypted['nonce']),
]);

if ($json === false) {
throw new \RuntimeException("Failed to encode encrypted data for {$key}");
}

return $json;
}
}
59 changes: 59 additions & 0 deletions app/Console/Commands/GenerateTenantCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Console\Commands;

use App\Models\TenantKey;
use Illuminate\Console\Command;

/**
* Generate a new tenant with envelope keys.
*
* This command creates a new tenant entry in the tenant_keys table
* with fresh DEK and idx_key wrapped by the current KEK.
*/
class GenerateTenantCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'keys:generate-tenant';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate a new tenant with envelope keys';

/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('Generating new tenant envelope keys...');

try {
$keys = TenantKey::generateEnvelopeKeys();
$tenant = TenantKey::create($keys);

$this->info("βœ… Successfully created tenant {$tenant->id}");
$this->line(' DEK wrapped: '.strlen($tenant->dek_wrapped).' bytes');
$this->line(' idx_key wrapped: '.strlen($tenant->idx_wrapped).' bytes');
$this->line(" Key version: {$tenant->key_version}");

return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ Failed to generate tenant: '.$e->getMessage());

return Command::FAILURE;
}
}
}
111 changes: 111 additions & 0 deletions app/Console/Commands/RebuildIndexCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/*
* SPDX-FileCopyrightText: 2025 SecPal Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace App\Console\Commands;

use App\Models\Person;
use App\Models\TenantKey;
use Illuminate\Console\Command;

/**
* Rebuild blind indexes for a specific tenant.
*
* This command:
* 1. Loads all Person records for the tenant
* 2. Decrypts email_enc and phone_enc
* 3. Regenerates blind indexes using the current idx_key
* 4. Updates the Person records
*
* Use cases:
* - After idx_key rotation
* - After index corruption
* - After normalization rules change
*/
class RebuildIndexCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'idx:rebuild {tenant : The tenant ID}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Rebuild blind indexes for a specific tenant';

/**
* Execute the console command.
*/
public function handle(): int
{
$tenantId = $this->argument('tenant');

$this->info("Rebuilding blind indexes for tenant {$tenantId}...");

try {
// Find tenant
$tenant = TenantKey::find($tenantId);

if (! $tenant) {
$this->error("Tenant {$tenantId} not found.");

return Command::FAILURE;
}

// Get all Person records
$persons = Person::where('tenant_id', $tenantId)->get();
$this->info("Processing {$persons->count()} record(s)...");

$bar = $this->output->createProgressBar($persons->count());

foreach ($persons as $person) {
// Decrypt and rebuild email_idx
if ($person->getAttributes()['email_enc']) {
$emailPlain = $person->email_enc; // Uses cast to decrypt
if ($emailPlain !== null && $emailPlain !== '') {
$normalized = strtolower(trim($emailPlain));
$rawIdx = $tenant->generateBlindIndex($normalized);
$person->email_idx = base64_encode($rawIdx); // Store as base64
}
}

// Decrypt and rebuild phone_idx
if ($person->getAttributes()['phone_enc']) {
$phonePlain = $person->phone_enc; // Uses cast to decrypt
if ($phonePlain !== null && $phonePlain !== '') {
$normalized = preg_replace('/\D/', '', $phonePlain);
if ($normalized !== null) {
$rawIdx = $tenant->generateBlindIndex($normalized);
$person->phone_idx = base64_encode($rawIdx); // Store as base64
}
}
}

$person->saveQuietly(); // Avoid triggering observers

$bar->advance();
}

$bar->finish();
$this->newLine();

$this->info("βœ… Rebuilt indexes for {$persons->count()} record(s)");
$this->info('βœ… Index rebuild complete!');

return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ Index rebuild failed: '.$e->getMessage());

return Command::FAILURE;
}
}
}
Loading