Darkwood · BYOK · Symfony 8.2 · Navi

SECRET KEYS
BECOME
APPLICATION DATA

BYOK × Symfony 8.2 × Navi

@matyo91

The old model

ANTHROPIC_API_KEY=...

The secret belongs to the deployment.

operator → deployment → application

01 | .env @matyo91

Ownership

What happens when the key
belongs to the user?

Deployment

.env
static
one credential
boot-time
configuration

User

HTTP
dynamic
many credentials
runtime
application data

.env is no longer the answer.

02 | BYOK @matyo91

The experiment

A secret now has a lifecycle

User
 ↓
Symfony Form
 ↓
encrypt
 ↓
SQLite ciphertext
 ↓
decrypt
 ↓
Navi
 ↓
Symfony AI
 ↓
Anthropic

Four verbs

INPUT
STORE
USE
OBSERVE
03 | lifecycle @matyo91

Boundaries

Keep every layer small

Responsibilities

FORM — accept credential
KEY MANAGEMENT — encrypt / decrypt
DATABASE — persist ciphertext
NAVI — orchestrate
SYMFONY AI — talk to provider

AiConnection
    ↓
CredentialProtector::decrypt()
    ↓
WorkflowRunner
    ↓
TestAiConnectionAction
    ↓
Anthropic\Factory::createPlatform()

Navi is a workflow shell, not an LLM SDK.

04 | layers @matyo91

Symfony 8.2 · experimental

Symfony gets the missing primitive

$ciphertext = $this->encrypter->encrypt(
    $this->keyId,
    $apiKey,
);

$connection->setCiphertextBlob(
    base64_encode($ciphertext->blob),
);
$connection->setKmsKeyId($ciphertext->keyId);
Ciphertext = blob + key id
8.2.x-dev · experimental
direct encrypt · not envelope

dev / test

Sodium

Tested

prod

AWS KMS

Configured only
05 | KeyManagement @matyo91

HTTP input

The form becomes a security boundary

#[AsFormType]
final class AiConnectionInput
{
    #[FormField(PasswordType::class, [
        'always_empty' => true,
        'attr' => ['autocomplete' => 'new-password'],
    ])]
    public ?string $apiKey = null;
}
password field
never redisplayed
blank edit = keep ciphertext

The key used to enter through dotenv. Now it enters through HTTP.

06 | form @matyo91

Evidence

What SQLite actually contains

$ sqlite3 var/data.db \
  "SELECT kms_key_id,
          length(ciphertext_blob),
          instr(ciphertext_blob, 'sk-ant')
   FROM ai_connection;"

app-key|96|0

PLAINTEXT FOUND: 0

Evidence, not a cryptographic audit.

07 | ciphertext @matyo91

Execution boundary

Decrypt at the last responsible moment

$apiKey = $this->credentials->decrypt($connection);

try {
    $this->workflows->run(
        Context::fromArray([
            'connection_id' => $connection->getId(),
            'provider' => $connection->getProvider(),
            'model' => $connection->getModel(),
        ]),
        [new TestAiConnectionAction($apiKey, ...)],
    );
} finally {
    $apiKey = str_repeat("\0", strlen($apiKey));
}

Navi Context

connection_id
provider
model

SECRET
NOT HERE

context_has_secret: no

08 | decrypt @matyo91

Live probe

Failure is still evidence

plaintext_in_ciphertext: no
decrypt_matches: yes
context_has_secret: no
test_ok: false

Anthropic:
credit balance too low

How far it travelled

encrypt          ✓
persist          ✓
decrypt          ✓
Navi             ✓
Symfony AI       ✓
Anthropic        ✓
completion       ✕

A billing error still tells us how far the request travelled.

Proves a usable credential reached the provider. Not completion, token usage, or a cost receipt.

09 | probe @matyo91

Observability · Cost of a diff

An AI credential has an
economic blast radius

API KEY
MODEL ACCESS
TOKENS
$
cost =
  input × input_rate
+ output × output_rate
+ cache_write × rate
+ cache_read × rate

per 1M tokens

Status

Implemented
Not validated against
a successful provider receipt

42 / 8 = simulated counts
10 | cost @matyo91

Transport

Provider ≠ endpoint ≠ credential storage

Anthropic bridge
      │
      ├── api.anthropic.com
      │
      └── Azure AI compatible
          endpoint

Reported Azure shape

<resource>.services.ai.azure.com
/anthropic/v1/messages

Azure baseUrl: wired, not called

Sometimes compatibility is configuration, not another abstraction.

11 | endpoint @matyo91

Key custody

Sodium locally, KMS outside

Sodium

application
    ↓
encryption key
    ↓
ciphertext
Tested

AWS KMS

application
    ↓
KMS API
    ↓
master key outside app
Configured · not called

External KMS improves key management. It does not make a compromised runtime safe.

12 | KMS @matyo91

Scoreboard

What actually ran

Verified

container / Twig / YAML lint
PHPUnit · 2 tests · 15 assertions
Browser BYOK flow
encrypted badge · blank key on edit
invalid-key error
SQLite plaintext search = 0
Live Anthropic reached
billing error

Not verified

AWS KMS call
Azure Anthropic call
successful completion
real token receipt
provider-validated cost

Statuses

implemented · configured
tested · verified

13 | evidence @matyo91

Ownership is the architecture

ANTHROPIC_API_KEY=...
user
 ↓
input
 ↓
encryption
 ↓
persistence
 ↓
execution
 ↓
observability
 ↓
rotation

.env is enough when the secret belongs to the deployment.

Once it belongs to a user, it needs an application lifecycle.

KeyManagement → protect it
Navi → orchestrate it
Symfony AI → use it

Each layer can stay small.

@matyo91