diff --git a/.github/workflows/bruno-tests.yml b/.github/workflows/bruno-tests.yml index ccfaaed..7dda7a6 100644 --- a/.github/workflows/bruno-tests.yml +++ b/.github/workflows/bruno-tests.yml @@ -73,6 +73,11 @@ jobs: echo "Running users & tokens integration tests..." cd bruno/users_tokens_integration_tests && bru run --env local + - name: Run Certificates Integration Tests + run: | + echo "Running certificates integration tests..." + cd bruno/certificates_integration_tests && bru run --env local + - name: Stop server if: always() run: | diff --git a/README.md b/README.md index fe11548..f962453 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,16 @@ Self-hosted secrets management with web UI and REST API. ## Features -- Web UI with dark mode -- SQLite storage (single binary, no dependencies) -- Multi-user with JWT authentication -- API tokens with pattern-based permissions -- Audit logging +- 🔐 Web UI with dark mode +- 💾 SQLite storage (single binary, no dependencies) +- 👥 Multi-user with JWT authentication +- 🔑 API tokens with pattern-based permissions +- 📜 Audit logging +- 🔏 Certificate & Key Management (RSA, ECDSA, ED25519) + - Generate key pairs + - Import/Export certificates (PEM format) + - Create self-signed & CA-signed certificates + - Certificate verification & validation ## Screenshots @@ -109,9 +114,138 @@ Then use `Authorization: Bearer ` for: | POST | `/api/secrets` | Create secret | | PUT | `/api/secrets?key=` | Update secret | | DELETE | `/api/secrets?key=` | Delete secret | -| GET/POST/PUT/DELETE | `/api/users` | Manage users | -| GET/POST/PUT/DELETE | `/api/tokens` | Manage tokens | -| GET/POST/PUT/DELETE | `/api/permissions` | Manage permissions | +| GET/POST/PUT/DELETE | `/api/users` | Manage users | +| GET/POST/PUT/DELETE | `/api/tokens` | Manage tokens | +| GET/POST/PUT/DELETE | `/api/permissions` | Manage permissions | +| GET/POST/DELETE | `/api/certificates` | Manage certificates | + +## Certificate Management + +The secrets manager includes comprehensive certificate and key management capabilities: + +### Generate Key Pairs + +Generate RSA, ECDSA, or ED25519 key pairs: + +```bash +POST /api/certificates/generate-keypair +Authorization: Bearer +Content-Type: application/json + +{ + "name": "my-keypair", + "algorithm": "RSA", // "RSA", "ECDSA", or "ED25519" + "key_size": 2048 // RSA: 2048, 3072, 4096; ECDSA: 256, 384, 521 +} +``` + +### Generate Certificates + +Create self-signed or CA-signed X.509 certificates: + +```bash +POST /api/certificates/generate-certificate +Authorization: Bearer +Content-Type: application/json + +{ + "name": "my-cert", + "private_key_name": "my-keypair-private", + "subject": { + "common_name": "example.com", + "organization": "My Organization", + "country": "US" + }, + "validity_days": 365, + "is_ca": false, + "dns_names": ["example.com", "www.example.com"], + "signing_cert_name": "ca-cert" // Optional: for CA-signed certs +} +``` + +### Import/Export Certificates + +```bash +# Import a certificate +POST /api/certificates/import +Authorization: Bearer +Content-Type: application/json + +{ + "name": "imported-cert", + "cert_type": "certificate", // "private_key", "public_key", "certificate", "ca_certificate" + "pem_data": "-----BEGIN CERTIFICATE-----\n..." +} + +# Export a certificate +GET /api/certificates/{name}/export +Authorization: Bearer +``` + +### Verify Certificates + +Verify certificate validity and signatures: + +```bash +POST /api/certificates/verify +Authorization: Bearer +Content-Type: application/json + +{ + "certificate_name": "my-cert", + "ca_cert_name": "ca-cert" // Optional: verify against specific CA +} +``` + +### Go SDK - Certificate Management + +```go +// Generate RSA key pair +keyPair, err := client.GenerateKeyPair(secretssdk.GenerateKeyPairRequest{ + Name: "my-rsa-key", + Algorithm: "RSA", + KeySize: ptrInt(2048), +}) + +// Generate self-signed certificate +cert, err := client.GenerateCertificate(secretssdk.GenerateCertificateRequest{ + Name: "my-cert", + PrivateKeyName: "my-rsa-key-private", + Subject: secretssdk.Subject{ + CommonName: "example.com", + Organization: "My Org", + Country: "US", + }, + ValidityDays: 365, + IsCA: false, + DNSNames: []string{"example.com", "*.example.com"}, +}) + +// Verify certificate +result, err := client.VerifyCertificate(secretssdk.VerifyCertificateRequest{ + CertificateName: "my-cert", +}) + +// Export certificate +exported, err := client.ExportCertificate("my-cert") +fmt.Println(exported.PemData) +``` + +### List & Manage Certificates + +```bash +# List all certificates +GET /api/certificates +Authorization: Bearer + +# Get specific certificate +GET /api/certificates/{name} +Authorization: Bearer + +# Delete certificate +DELETE /api/certificates/{name} +Authorization: Bearer +``` ## Pattern Matching @@ -138,5 +272,9 @@ cd web && yarn dev # Runs on localhost:5173, proxies API to :7770 Integration tests (requires [Bruno CLI](https://www.usebruno.com/)): ```bash +# Run all test collections cd bruno/auth_integration_tests && bru run --env local +cd bruno/secrets_integration_tests && bru run --env local +cd bruno/users_tokens_integration_tests && bru run --env local +cd bruno/certificates_integration_tests && bru run --env local ``` diff --git a/bruno/certificates_integration_tests/01 - Login as admin.bru b/bruno/certificates_integration_tests/01 - Login as admin.bru new file mode 100644 index 0000000..47b2736 --- /dev/null +++ b/bruno/certificates_integration_tests/01 - Login as admin.bru @@ -0,0 +1,38 @@ +meta { + name: 01 - Login as admin + type: http + seq: 1 +} + +post { + url: {{burl}}/login + body: json + auth: none +} + +body:json { + { + "username": "admin", + "password": "{{admin_password}}" + } +} + +script:post-response { + if (res.body.data && res.body.data.token) { + bru.setEnvVar("admin_token", res.body.data.token); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.token: isDefined +} + +tests { + test("Login should succeed with correct credentials", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().success).to.be.true; + expect(res.getBody().data.token).to.be.a('string'); + }); +} diff --git a/bruno/certificates_integration_tests/02 - Generate RSA key pair.bru b/bruno/certificates_integration_tests/02 - Generate RSA key pair.bru new file mode 100644 index 0000000..06defba --- /dev/null +++ b/bruno/certificates_integration_tests/02 - Generate RSA key pair.bru @@ -0,0 +1,49 @@ +meta { + name: 02 - Generate RSA key pair + type: http + seq: 2 +} + +post { + url: {{burl}}/api/certificates/generate-keypair + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-rsa", + "algorithm": "RSA", + "key_size": 2048 + } +} + +script:post-response { + if (res.body.data && res.body.data.private_key) { + bru.setEnvVar("rsa_private_key_name", res.body.data.private_key.name); + bru.setEnvVar("rsa_public_key_name", res.body.data.public_key.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.private_key.name: eq test-rsa-private + res.body.data.public_key.name: eq test-rsa-public + res.body.data.private_key.algorithm: eq RSA + res.body.data.private_key.cert_type: eq private_key + res.body.data.public_key.cert_type: eq public_key +} + +tests { + test("RSA key pair generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.private_key.pem_data).to.contain("RSA PRIVATE KEY"); + expect(res.getBody().data.public_key.pem_data).to.contain("PUBLIC KEY"); + }); +} diff --git a/bruno/certificates_integration_tests/03 - Generate ECDSA key pair.bru b/bruno/certificates_integration_tests/03 - Generate ECDSA key pair.bru new file mode 100644 index 0000000..cf3701d --- /dev/null +++ b/bruno/certificates_integration_tests/03 - Generate ECDSA key pair.bru @@ -0,0 +1,46 @@ +meta { + name: 03 - Generate ECDSA key pair + type: http + seq: 3 +} + +post { + url: {{burl}}/api/certificates/generate-keypair + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-ecdsa", + "algorithm": "ECDSA", + "key_size": 256 + } +} + +script:post-response { + if (res.body.data && res.body.data.private_key) { + bru.setEnvVar("ecdsa_private_key_name", res.body.data.private_key.name); + bru.setEnvVar("ecdsa_public_key_name", res.body.data.public_key.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.private_key.name: eq test-ecdsa-private + res.body.data.public_key.name: eq test-ecdsa-public + res.body.data.private_key.algorithm: eq ECDSA +} + +tests { + test("ECDSA key pair generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.private_key.pem_data).to.contain("EC PRIVATE KEY"); + }); +} diff --git a/bruno/certificates_integration_tests/04 - Generate ED25519 key pair.bru b/bruno/certificates_integration_tests/04 - Generate ED25519 key pair.bru new file mode 100644 index 0000000..273f865 --- /dev/null +++ b/bruno/certificates_integration_tests/04 - Generate ED25519 key pair.bru @@ -0,0 +1,43 @@ +meta { + name: 04 - Generate ED25519 key pair + type: http + seq: 4 +} + +post { + url: {{burl}}/api/certificates/generate-keypair + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-ed25519", + "algorithm": "ED25519" + } +} + +script:post-response { + if (res.body.data && res.body.data.private_key) { + bru.setEnvVar("ed25519_private_key_name", res.body.data.private_key.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.private_key.name: eq test-ed25519-private + res.body.data.private_key.algorithm: eq ED25519 +} + +tests { + test("ED25519 key pair generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.private_key.pem_data).to.contain("PRIVATE KEY"); + }); +} diff --git a/bruno/certificates_integration_tests/05 - List all certificates.bru b/bruno/certificates_integration_tests/05 - List all certificates.bru new file mode 100644 index 0000000..c30e572 --- /dev/null +++ b/bruno/certificates_integration_tests/05 - List all certificates.bru @@ -0,0 +1,28 @@ +meta { + name: 05 - List all certificates + type: http + seq: 5 +} + +get { + url: {{burl}}/api/certificates + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true +} + +tests { + test("Should list all certificates", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data).to.be.an('array'); + expect(res.getBody().data.length).to.be.at.least(6); + }); +} diff --git a/bruno/certificates_integration_tests/06 - Generate self-signed certificate.bru b/bruno/certificates_integration_tests/06 - Generate self-signed certificate.bru new file mode 100644 index 0000000..0a09f3a --- /dev/null +++ b/bruno/certificates_integration_tests/06 - Generate self-signed certificate.bru @@ -0,0 +1,53 @@ +meta { + name: 06 - Generate self-signed certificate + type: http + seq: 6 +} + +post { + url: {{burl}}/api/certificates/generate-certificate + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-self-signed-cert", + "private_key_name": "test-rsa-private", + "subject": { + "common_name": "test.example.com", + "organization": "Test Organization", + "country": "US" + }, + "validity_days": 365, + "is_ca": false, + "dns_names": ["test.example.com", "www.test.example.com"] + } +} + +script:post-response { + if (res.body.data && res.body.data.name) { + bru.setEnvVar("self_signed_cert_name", res.body.data.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.name: eq test-self-signed-cert + res.body.data.cert_type: eq certificate +} + +tests { + test("Self-signed certificate generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.pem_data).to.contain("CERTIFICATE"); + const metadata = JSON.parse(res.getBody().data.metadata); + expect(metadata.subject).to.contain("test.example.com"); + }); +} diff --git a/bruno/certificates_integration_tests/07 - Generate CA certificate.bru b/bruno/certificates_integration_tests/07 - Generate CA certificate.bru new file mode 100644 index 0000000..dd73469 --- /dev/null +++ b/bruno/certificates_integration_tests/07 - Generate CA certificate.bru @@ -0,0 +1,42 @@ +meta { + name: 07 - Generate CA key pair + type: http + seq: 7 +} + +post { + url: {{burl}}/api/certificates/generate-keypair + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-ca", + "algorithm": "RSA", + "key_size": 2048 + } +} + +script:post-response { + if (res.body.data && res.body.data.private_key) { + bru.setEnvVar("ca_private_key_name", res.body.data.private_key.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.private_key.name: eq test-ca-private +} + +tests { + test("CA key pair generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/certificates_integration_tests/08 - Generate CA certificate.bru b/bruno/certificates_integration_tests/08 - Generate CA certificate.bru new file mode 100644 index 0000000..082f026 --- /dev/null +++ b/bruno/certificates_integration_tests/08 - Generate CA certificate.bru @@ -0,0 +1,51 @@ +meta { + name: 08 - Generate CA certificate + type: http + seq: 8 +} + +post { + url: {{burl}}/api/certificates/generate-certificate + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-ca-cert", + "private_key_name": "test-ca-private", + "subject": { + "common_name": "Test CA", + "organization": "Test CA Organization", + "country": "US" + }, + "validity_days": 3650, + "is_ca": true + } +} + +script:post-response { + if (res.body.data && res.body.data.name) { + bru.setEnvVar("ca_cert_name", res.body.data.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.name: eq test-ca-cert + res.body.data.cert_type: eq ca_certificate +} + +tests { + test("CA certificate generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + const metadata = JSON.parse(res.getBody().data.metadata); + expect(metadata.is_ca).to.be.true; + }); +} diff --git a/bruno/certificates_integration_tests/09 - Generate key for CA-signed cert.bru b/bruno/certificates_integration_tests/09 - Generate key for CA-signed cert.bru new file mode 100644 index 0000000..c7d4e17 --- /dev/null +++ b/bruno/certificates_integration_tests/09 - Generate key for CA-signed cert.bru @@ -0,0 +1,35 @@ +meta { + name: 09 - Generate key for CA-signed cert + type: http + seq: 9 +} + +post { + url: {{burl}}/api/certificates/generate-keypair + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-ca-signed-key", + "algorithm": "RSA", + "key_size": 2048 + } +} + +script:post-response { + if (res.body.data && res.body.data.private_key) { + bru.setEnvVar("ca_signed_key_name", res.body.data.private_key.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true +} diff --git a/bruno/certificates_integration_tests/10 - Create CA-signed certificate.bru b/bruno/certificates_integration_tests/10 - Create CA-signed certificate.bru new file mode 100644 index 0000000..f936b27 --- /dev/null +++ b/bruno/certificates_integration_tests/10 - Create CA-signed certificate.bru @@ -0,0 +1,58 @@ +meta { + name: 10 - Create CA-signed certificate + type: http + seq: 10 +} + +post { + url: {{burl}}/api/certificates/generate-certificate + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "name": "test-ca-signed-cert", + "private_key_name": "test-ca-signed-key-private", + "subject": { + "common_name": "signed.example.com", + "organization": "Signed Organization", + "country": "US" + }, + "validity_days": 365, + "is_ca": false, + "dns_names": ["signed.example.com"], + "signing_cert_name": "test-ca-cert" + } +} + +script:post-response { + if (res.body.data && res.body.data.name) { + bru.setEnvVar("ca_signed_cert_name", res.body.data.name); + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.name: eq test-ca-signed-cert +} + +tests { + test("CA-signed certificate generation should succeed", function() { + expect(res.getStatus()).to.equal(200); + const metadata = JSON.parse(res.getBody().data.metadata); + expect(metadata.subject).to.contain("signed.example.com"); + }); + + test("Note: CA cert uses test-rsa-private key", function() { + // The CA certificate (test-ca-cert) was created using test-rsa-private + // So to sign with it, we need test-rsa-private, not test-ca-cert-private + expect(true).to.be.true; + }); +} diff --git a/bruno/certificates_integration_tests/11 - Verify self-signed certificate.bru b/bruno/certificates_integration_tests/11 - Verify self-signed certificate.bru new file mode 100644 index 0000000..88553cf --- /dev/null +++ b/bruno/certificates_integration_tests/11 - Verify self-signed certificate.bru @@ -0,0 +1,36 @@ +meta { + name: 11 - Verify self-signed certificate + type: http + seq: 11 +} + +post { + url: {{burl}}/api/certificates/verify + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "certificate_name": "test-self-signed-cert" + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.self_signed: eq true +} + +tests { + test("Self-signed certificate verification should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.self_signed).to.be.true; + expect(res.getBody().data.subject).to.contain("test.example.com"); + }); +} diff --git a/bruno/certificates_integration_tests/12 - Verify CA-signed certificate.bru b/bruno/certificates_integration_tests/12 - Verify CA-signed certificate.bru new file mode 100644 index 0000000..7b40dc9 --- /dev/null +++ b/bruno/certificates_integration_tests/12 - Verify CA-signed certificate.bru @@ -0,0 +1,37 @@ +meta { + name: 12 - Verify CA-signed certificate + type: http + seq: 12 +} + +post { + url: {{burl}}/api/certificates/verify + body: json + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} + Content-Type: application/json +} + +body:json { + { + "certificate_name": "test-ca-signed-cert", + "ca_cert_name": "test-ca-cert" + } +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.valid: eq true +} + +tests { + test("CA-signed certificate verification should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.valid).to.be.true; + expect(res.getBody().data.subject).to.contain("signed.example.com"); + }); +} diff --git a/bruno/certificates_integration_tests/13 - Export certificate.bru b/bruno/certificates_integration_tests/13 - Export certificate.bru new file mode 100644 index 0000000..cebf235 --- /dev/null +++ b/bruno/certificates_integration_tests/13 - Export certificate.bru @@ -0,0 +1,28 @@ +meta { + name: 13 - Export certificate + type: http + seq: 13 +} + +get { + url: {{burl}}/api/certificates/test-self-signed-cert/export + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.name: eq test-self-signed-cert +} + +tests { + test("Certificate export should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.pem_data).to.contain("CERTIFICATE"); + }); +} diff --git a/bruno/certificates_integration_tests/14 - Get specific certificate.bru b/bruno/certificates_integration_tests/14 - Get specific certificate.bru new file mode 100644 index 0000000..17d0d1d --- /dev/null +++ b/bruno/certificates_integration_tests/14 - Get specific certificate.bru @@ -0,0 +1,29 @@ +meta { + name: 14 - Get specific certificate + type: http + seq: 14 +} + +get { + url: {{burl}}/api/certificates/test-rsa-private + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true + res.body.data.name: eq test-rsa-private + res.body.data.algorithm: eq RSA +} + +tests { + test("Get specific certificate should succeed", function() { + expect(res.getStatus()).to.equal(200); + expect(res.getBody().data.cert_type).to.equal("private_key"); + }); +} diff --git a/bruno/certificates_integration_tests/15 - Cleanup - Delete certificates.bru b/bruno/certificates_integration_tests/15 - Cleanup - Delete certificates.bru new file mode 100644 index 0000000..b690bc0 --- /dev/null +++ b/bruno/certificates_integration_tests/15 - Cleanup - Delete certificates.bru @@ -0,0 +1,26 @@ +meta { + name: 15 - Cleanup - Delete certificates + type: http + seq: 15 +} + +delete { + url: {{burl}}/api/certificates/test-self-signed-cert + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true +} + +tests { + test("Certificate deletion should succeed", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/certificates_integration_tests/16 - Cleanup - Delete CA cert.bru b/bruno/certificates_integration_tests/16 - Cleanup - Delete CA cert.bru new file mode 100644 index 0000000..c117bc7 --- /dev/null +++ b/bruno/certificates_integration_tests/16 - Cleanup - Delete CA cert.bru @@ -0,0 +1,20 @@ +meta { + name: 16 - Cleanup - Delete CA cert + type: http + seq: 16 +} + +delete { + url: {{burl}}/api/certificates/test-ca-cert + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true +} diff --git a/bruno/certificates_integration_tests/17 - Cleanup - Delete CA-signed cert.bru b/bruno/certificates_integration_tests/17 - Cleanup - Delete CA-signed cert.bru new file mode 100644 index 0000000..b87fe25 --- /dev/null +++ b/bruno/certificates_integration_tests/17 - Cleanup - Delete CA-signed cert.bru @@ -0,0 +1,20 @@ +meta { + name: 17 - Cleanup - Delete CA-signed cert + type: http + seq: 17 +} + +delete { + url: {{burl}}/api/certificates/test-ca-signed-cert + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true +} diff --git a/bruno/certificates_integration_tests/18 - Cleanup - Delete key pairs.bru b/bruno/certificates_integration_tests/18 - Cleanup - Delete key pairs.bru new file mode 100644 index 0000000..374a4ed --- /dev/null +++ b/bruno/certificates_integration_tests/18 - Cleanup - Delete key pairs.bru @@ -0,0 +1,20 @@ +meta { + name: 18 - Cleanup - Delete key pairs + type: http + seq: 18 +} + +delete { + url: {{burl}}/api/certificates/test-rsa-private + body: none + auth: inherit +} + +headers { + Authorization: Bearer {{admin_token}} +} + +assert { + res.status: eq 200 + res.body.success: eq true +} diff --git a/bruno/certificates_integration_tests/bruno.json b/bruno/certificates_integration_tests/bruno.json new file mode 100644 index 0000000..57760ad --- /dev/null +++ b/bruno/certificates_integration_tests/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "certificates_integration_tests", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/bruno/certificates_integration_tests/environments/local.bru b/bruno/certificates_integration_tests/environments/local.bru new file mode 100644 index 0000000..05e050c --- /dev/null +++ b/bruno/certificates_integration_tests/environments/local.bru @@ -0,0 +1,4 @@ +vars { + burl: http://127.0.0.1:7770 + admin_password: TestAdminPassword123! +} diff --git a/internal/secrets/5_certificates.go b/internal/secrets/5_certificates.go new file mode 100644 index 0000000..d310920 --- /dev/null +++ b/internal/secrets/5_certificates.go @@ -0,0 +1,762 @@ +package secrets + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/tomek7667/go-http-helpers/chii" + "github.com/tomek7667/go-http-helpers/h" + "github.com/tomek7667/go-http-helpers/utils" + "github.com/tomek7667/secrets/internal/sqlc" +) + +type GenerateKeyPairDto struct { + Name string `json:"name"` + Algorithm string `json:"algorithm"` // "RSA", "ECDSA", "ED25519" + KeySize *int64 `json:"key_size"` // For RSA: 2048, 3072, 4096; For ECDSA: 256, 384, 521 +} + +type ImportCertificateDto struct { + Name string `json:"name"` + CertType string `json:"cert_type"` // "private_key", "public_key", "certificate", "ca_certificate" + PemData string `json:"pem_data"` +} + +type GenerateCertificateDto struct { + Name string `json:"name"` + PrivateKeyName string `json:"private_key_name"` + Subject Subject `json:"subject"` + ValidityDays int `json:"validity_days"` + IsCA bool `json:"is_ca"` + DNSNames []string `json:"dns_names,omitempty"` + EmailAddresses []string `json:"email_addresses,omitempty"` + SigningCertName *string `json:"signing_cert_name,omitempty"` // For CA-signed certificates +} + +type Subject struct { + CommonName string `json:"common_name"` + Organization string `json:"organization,omitempty"` + OrganizationalUnit string `json:"organizational_unit,omitempty"` + Country string `json:"country,omitempty"` + Province string `json:"province,omitempty"` + Locality string `json:"locality,omitempty"` +} + +type VerifyCertificateDto struct { + CertificateName string `json:"certificate_name"` + CACertName *string `json:"ca_cert_name,omitempty"` // Optional: verify against specific CA +} + +type CertificateMetadata struct { + Issuer string `json:"issuer,omitempty"` + Subject string `json:"subject,omitempty"` + NotBefore time.Time `json:"not_before,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + IsCA bool `json:"is_ca,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` + KeyUsage []string `json:"key_usage,omitempty"` +} + +func (s *Server) AddCertificatesRoutes() { + auth := s.Router.With(chii.WithAuth(s.auther)) + auth.Route("/api/certificates", func(r chi.Router) { + // List all certificates + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + certificates, err := s.Db.Queries.ListCertificates(r.Context()) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("failed to list certificates for user %s: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + s.Log(GetSecretsEvent, fmt.Sprintf("%s retrieved certificates", user.ID), r) + h.ResSuccess(w, certificates) + }) + + // Get certificate by name + r.Get("/{name}", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + name := chi.URLParam(r, "name") + certificate, err := s.Db.Queries.GetCertificate(r.Context(), name) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to get certificate '%s' but an error happened: %s", user.ID, name, err.Error()), r) + h.ResNotFound(w, "certificate") + return + } + s.Log(GetSecretsEvent, fmt.Sprintf("%s retrieved certificate %s", user.ID, name), r) + h.ResSuccess(w, certificate) + }) + + // Generate key pair + r.Post("/generate-keypair", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + dto, err := h.GetDto[GenerateKeyPairDto](r) + if err != nil { + h.ResBadRequest(w, err) + return + } + + privateKeyPEM, publicKeyPEM, algorithm, keySize, err := generateKeyPair(dto.Algorithm, dto.KeySize) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to generate key pair: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + // Store private key + privateKeyCert, err := s.Db.Queries.CreateCertificate(r.Context(), sqlc.CreateCertificateParams{ + ID: utils.CreateUUID(), + Name: dto.Name + "-private", + CertType: "private_key", + Algorithm: algorithm, + KeySize: &keySize, + PemData: privateKeyPEM, + Metadata: nil, + }) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to store private key: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + // Store public key + publicKeyCert, err := s.Db.Queries.CreateCertificate(r.Context(), sqlc.CreateCertificateParams{ + ID: utils.CreateUUID(), + Name: dto.Name + "-public", + CertType: "public_key", + Algorithm: algorithm, + KeySize: &keySize, + PemData: publicKeyPEM, + Metadata: nil, + }) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to store public key: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + s.Log(IngestEvent, fmt.Sprintf("user %s generated key pair %s", user.ID, dto.Name), r) + h.ResSuccess(w, map[string]interface{}{ + "private_key": privateKeyCert, + "public_key": publicKeyCert, + }) + }) + + // Import certificate + r.Post("/import", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + dto, err := h.GetDto[ImportCertificateDto](r) + if err != nil { + h.ResBadRequest(w, err) + return + } + + // Parse and validate PEM data + block, _ := pem.Decode([]byte(dto.PemData)) + if block == nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to import invalid PEM data", user.ID), r) + h.ResBadRequest(w, fmt.Errorf("invalid PEM data")) + return + } + + algorithm, keySize, metadata, err := parsePEMBlock(block, dto.CertType) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to parse PEM: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + metadataJSON, _ := json.Marshal(metadata) + metadataStr := string(metadataJSON) + + certificate, err := s.Db.Queries.CreateCertificate(r.Context(), sqlc.CreateCertificateParams{ + ID: utils.CreateUUID(), + Name: dto.Name, + CertType: dto.CertType, + Algorithm: algorithm, + KeySize: keySize, + PemData: dto.PemData, + Metadata: &metadataStr, + }) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to import certificate: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + s.Log(IngestEvent, fmt.Sprintf("user %s imported certificate %s", user.ID, dto.Name), r) + h.ResSuccess(w, certificate) + }) + + // Export certificate (returns PEM data) + r.Get("/{name}/export", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + name := chi.URLParam(r, "name") + certificate, err := s.Db.Queries.GetCertificate(r.Context(), name) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to export certificate '%s' but an error happened: %s", user.ID, name, err.Error()), r) + h.ResNotFound(w, "certificate") + return + } + s.Log(GetSecretsEvent, fmt.Sprintf("%s exported certificate %s", user.ID, name), r) + h.ResSuccess(w, map[string]string{ + "name": certificate.Name, + "pem_data": certificate.PemData, + }) + }) + + // Generate certificate + r.Post("/generate-certificate", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + dto, err := h.GetDto[GenerateCertificateDto](r) + if err != nil { + h.ResBadRequest(w, err) + return + } + + // Get private key + privateKeyCert, err := s.Db.Queries.GetCertificate(r.Context(), dto.PrivateKeyName) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to use non-existent private key '%s'", user.ID, dto.PrivateKeyName), r) + h.ResNotFound(w, "private_key") + return + } + + // Parse private key + privateKey, err := parsePrivateKey(privateKeyCert.PemData) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to parse private key: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + var signingCert *x509.Certificate + var signingKey interface{} + + if dto.SigningCertName != nil { + // CA-signed certificate + signingCertData, err := s.Db.Queries.GetCertificate(r.Context(), *dto.SigningCertName) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to use non-existent CA certificate '%s'", user.ID, *dto.SigningCertName), r) + h.ResNotFound(w, "ca_certificate") + return + } + + signingCert, err = parseCertificate(signingCertData.PemData) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to parse CA certificate: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + // Get CA private key + // NOTE: This assumes the CA's private key follows the naming convention: {ca_cert_name}-private + // For example, if the CA certificate is named "my-ca", its private key must be named "my-ca-private" + // This is automatically handled when using the generate-keypair endpoint, which creates keys with -private suffix + caPrivateKeyName := *dto.SigningCertName + "-private" + caPrivateKeyCert, err := s.Db.Queries.GetCertificate(r.Context(), caPrivateKeyName) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to use non-existent CA private key '%s'", user.ID, caPrivateKeyName), r) + h.ResNotFound(w, "ca_private_key") + return + } + + signingKey, err = parsePrivateKey(caPrivateKeyCert.PemData) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to parse CA private key: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + } + + certPEM, metadata, err := generateCertificate(*dto, privateKey, signingCert, signingKey) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to generate certificate: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + metadataJSON, _ := json.Marshal(metadata) + metadataStr := string(metadataJSON) + + certType := "certificate" + if dto.IsCA { + certType = "ca_certificate" + } + + var keySize *int64 + if privateKeyCert.KeySize != nil { + keySize = privateKeyCert.KeySize + } + + certificate, err := s.Db.Queries.CreateCertificate(r.Context(), sqlc.CreateCertificateParams{ + ID: utils.CreateUUID(), + Name: dto.Name, + CertType: certType, + Algorithm: privateKeyCert.Algorithm, + KeySize: keySize, + PemData: certPEM, + Metadata: &metadataStr, + }) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to store certificate: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + s.Log(IngestEvent, fmt.Sprintf("user %s generated certificate %s", user.ID, dto.Name), r) + h.ResSuccess(w, certificate) + }) + + // Verify certificate + r.Post("/verify", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + dto, err := h.GetDto[VerifyCertificateDto](r) + if err != nil { + h.ResBadRequest(w, err) + return + } + + // Get certificate to verify + certData, err := s.Db.Queries.GetCertificate(r.Context(), dto.CertificateName) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to verify non-existent certificate '%s'", user.ID, dto.CertificateName), r) + h.ResNotFound(w, "certificate") + return + } + + cert, err := parseCertificate(certData.PemData) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to parse certificate: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + var verificationResult map[string]interface{} + + if dto.CACertName != nil { + // Verify against specific CA + caCertData, err := s.Db.Queries.GetCertificate(r.Context(), *dto.CACertName) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to use non-existent CA certificate '%s'", user.ID, *dto.CACertName), r) + h.ResNotFound(w, "ca_certificate") + return + } + + caCert, err := parseCertificate(caCertData.PemData) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s failed to parse CA certificate: %s", user.ID, err.Error()), r) + h.ResErr(w, err) + return + } + + verificationResult = verifyCertificate(cert, caCert) + } else { + // Self-verification (check if self-signed is valid) + verificationResult = verifyCertificate(cert, nil) + } + + s.Log(GetSecretsEvent, fmt.Sprintf("%s verified certificate %s", user.ID, dto.CertificateName), r) + h.ResSuccess(w, verificationResult) + }) + + // Delete certificate + r.Delete("/{name}", func(w http.ResponseWriter, r *http.Request) { + user := chii.GetUser[sqlc.User](r) + name := chi.URLParam(r, "name") + err := s.Db.Queries.DeleteCertificate(r.Context(), name) + if err != nil { + s.Log(ErrorEvent, fmt.Sprintf("user %s tried to delete certificate '%s' but an error happened: %s", user.ID, name, err.Error()), r) + h.ResNotFound(w, "certificate") + return + } + s.Log(DeleteEvent, fmt.Sprintf("%s deleted certificate %s", user.ID, name), r) + h.ResSuccess(w, map[string]string{"message": "certificate deleted"}) + }) + }) +} + +// Helper functions + +func generateKeyPair(algorithm string, keySize *int64) (privateKeyPEM, publicKeyPEM, algo string, size int64, err error) { + switch algorithm { + case "RSA": + rsaSize := int64(2048) + if keySize != nil { + rsaSize = *keySize + } + privateKey, err := rsa.GenerateKey(rand.Reader, int(rsaSize)) + if err != nil { + return "", "", "", 0, err + } + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + })) + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return "", "", "", 0, err + } + publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + })) + return privateKeyPEM, publicKeyPEM, "RSA", rsaSize, nil + + case "ECDSA": + var curve elliptic.Curve + ecdsaSize := int64(256) + if keySize != nil { + ecdsaSize = *keySize + } + switch ecdsaSize { + case 256: + curve = elliptic.P256() + case 384: + curve = elliptic.P384() + case 521: + curve = elliptic.P521() + default: + return "", "", "", 0, fmt.Errorf("unsupported ECDSA key size: %d", ecdsaSize) + } + + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return "", "", "", 0, err + } + privateKeyBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return "", "", "", 0, err + } + privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: privateKeyBytes, + })) + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return "", "", "", 0, err + } + publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + })) + return privateKeyPEM, publicKeyPEM, "ECDSA", ecdsaSize, nil + + case "ED25519": + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", "", 0, err + } + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return "", "", "", 0, err + } + privateKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyBytes, + })) + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return "", "", "", 0, err + } + publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + })) + return privateKeyPEM, publicKeyPEM, "ED25519", 256, nil + + default: + return "", "", "", 0, fmt.Errorf("unsupported algorithm: %s", algorithm) + } +} + +func parsePEMBlock(block *pem.Block, certType string) (algorithm string, keySize *int64, metadata *CertificateMetadata, err error) { + metadata = &CertificateMetadata{} + + switch certType { + case "certificate", "ca_certificate": + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", nil, nil, err + } + metadata.Issuer = cert.Issuer.String() + metadata.Subject = cert.Subject.String() + metadata.NotBefore = cert.NotBefore + metadata.NotAfter = cert.NotAfter + metadata.SerialNumber = cert.SerialNumber.String() + metadata.IsCA = cert.IsCA + metadata.DNSNames = cert.DNSNames + + switch cert.PublicKey.(type) { + case *rsa.PublicKey: + algorithm = "RSA" + rsaKey := cert.PublicKey.(*rsa.PublicKey) + size := int64(rsaKey.N.BitLen()) + keySize = &size + case *ecdsa.PublicKey: + algorithm = "ECDSA" + ecKey := cert.PublicKey.(*ecdsa.PublicKey) + size := int64(ecKey.Params().BitSize) + keySize = &size + case ed25519.PublicKey: + algorithm = "ED25519" + size := int64(256) + keySize = &size + } + return algorithm, keySize, metadata, nil + + case "private_key": + if block.Type == "RSA PRIVATE KEY" { + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", nil, nil, err + } + algorithm = "RSA" + size := int64(key.N.BitLen()) + keySize = &size + return algorithm, keySize, nil, nil + } else if block.Type == "EC PRIVATE KEY" { + key, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return "", nil, nil, err + } + algorithm = "ECDSA" + size := int64(key.Params().BitSize) + keySize = &size + return algorithm, keySize, nil, nil + } else if block.Type == "PRIVATE KEY" { + // PKCS8 format (ED25519 uses this) + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", nil, nil, err + } + switch key.(type) { + case ed25519.PrivateKey: + algorithm = "ED25519" + size := int64(256) + keySize = &size + return algorithm, keySize, nil, nil + case *rsa.PrivateKey: + rsaKey := key.(*rsa.PrivateKey) + algorithm = "RSA" + size := int64(rsaKey.N.BitLen()) + keySize = &size + return algorithm, keySize, nil, nil + case *ecdsa.PrivateKey: + ecKey := key.(*ecdsa.PrivateKey) + algorithm = "ECDSA" + size := int64(ecKey.Params().BitSize) + keySize = &size + return algorithm, keySize, nil, nil + } + } + + case "public_key": + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", nil, nil, err + } + switch publicKey.(type) { + case *rsa.PublicKey: + algorithm = "RSA" + rsaKey := publicKey.(*rsa.PublicKey) + size := int64(rsaKey.N.BitLen()) + keySize = &size + case *ecdsa.PublicKey: + algorithm = "ECDSA" + ecKey := publicKey.(*ecdsa.PublicKey) + size := int64(ecKey.Params().BitSize) + keySize = &size + case ed25519.PublicKey: + algorithm = "ED25519" + size := int64(256) + keySize = &size + } + return algorithm, keySize, nil, nil + } + + return "", nil, nil, fmt.Errorf("unsupported certificate type or PEM block") +} + +func parsePrivateKey(pemData string) (interface{}, error) { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM block") + } + + if block.Type == "RSA PRIVATE KEY" { + return x509.ParsePKCS1PrivateKey(block.Bytes) + } else if block.Type == "EC PRIVATE KEY" { + return x509.ParseECPrivateKey(block.Bytes) + } else if block.Type == "PRIVATE KEY" { + return x509.ParsePKCS8PrivateKey(block.Bytes) + } + + return nil, fmt.Errorf("unsupported private key type") +} + +func parseCertificate(pemData string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM block") + } + return x509.ParseCertificate(block.Bytes) +} + +func generateCertificate(dto GenerateCertificateDto, privateKey interface{}, signingCert *x509.Certificate, signingKey interface{}) (string, *CertificateMetadata, error) { + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", nil, err + } + + subject := pkix.Name{ + CommonName: dto.Subject.CommonName, + Organization: []string{dto.Subject.Organization}, + OrganizationalUnit: []string{dto.Subject.OrganizationalUnit}, + Country: []string{dto.Subject.Country}, + Province: []string{dto.Subject.Province}, + Locality: []string{dto.Subject.Locality}, + } + + notBefore := time.Now() + notAfter := notBefore.Add(time.Duration(dto.ValidityDays) * 24 * time.Hour) + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + DNSNames: dto.DNSNames, + EmailAddresses: dto.EmailAddresses, + } + + if dto.IsCA { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + var publicKey interface{} + switch key := privateKey.(type) { + case *rsa.PrivateKey: + publicKey = &key.PublicKey + case *ecdsa.PrivateKey: + publicKey = &key.PublicKey + case ed25519.PrivateKey: + publicKey = key.Public() + default: + return "", nil, fmt.Errorf("unsupported private key type") + } + + var parent *x509.Certificate + var signingPrivateKey interface{} + + if signingCert != nil && signingKey != nil { + // CA-signed certificate + parent = signingCert + signingPrivateKey = signingKey + } else { + // Self-signed certificate + parent = &template + signingPrivateKey = privateKey + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &template, parent, publicKey, signingPrivateKey) + if err != nil { + return "", nil, err + } + + certPEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + })) + + metadata := &CertificateMetadata{ + Subject: subject.String(), + NotBefore: notBefore, + NotAfter: notAfter, + SerialNumber: serialNumber.String(), + IsCA: dto.IsCA, + DNSNames: dto.DNSNames, + } + + if parent != nil { + metadata.Issuer = parent.Subject.String() + } + + return certPEM, metadata, nil +} + +func verifyCertificate(cert *x509.Certificate, caCert *x509.Certificate) map[string]interface{} { + result := map[string]interface{}{ + "valid": false, + "expired": false, + "not_yet_valid": false, + "self_signed": false, + "errors": []string{}, + } + + now := time.Now() + + // Check expiration + if now.Before(cert.NotBefore) { + result["not_yet_valid"] = true + result["errors"] = append(result["errors"].([]string), "certificate is not yet valid") + } + if now.After(cert.NotAfter) { + result["expired"] = true + result["errors"] = append(result["errors"].([]string), "certificate has expired") + } + + // Check if self-signed + if cert.Issuer.String() == cert.Subject.String() { + result["self_signed"] = true + // Verify self-signed signature + err := cert.CheckSignatureFrom(cert) + if err != nil { + result["errors"] = append(result["errors"].([]string), fmt.Sprintf("invalid self-signature: %s", err.Error())) + } + } + + // If CA cert provided, verify against it + if caCert != nil { + err := cert.CheckSignatureFrom(caCert) + if err != nil { + result["errors"] = append(result["errors"].([]string), fmt.Sprintf("signature verification failed: %s", err.Error())) + } else { + result["valid"] = true + } + } else if result["self_signed"].(bool) && len(result["errors"].([]string)) == 0 { + result["valid"] = true + } + + // Add certificate details + result["subject"] = cert.Subject.String() + result["issuer"] = cert.Issuer.String() + result["not_before"] = cert.NotBefore + result["not_after"] = cert.NotAfter + result["serial_number"] = cert.SerialNumber.String() + result["is_ca"] = cert.IsCA + result["dns_names"] = cert.DNSNames + + return result +} diff --git a/internal/secrets/setuproutes.go b/internal/secrets/setuproutes.go index c66f2bb..4d5a3dd 100644 --- a/internal/secrets/setuproutes.go +++ b/internal/secrets/setuproutes.go @@ -7,4 +7,5 @@ func (s *Server) SetupRoutes() { s.AddSecretsRoutes() s.AddTokensRoutes() s.AddPermissionsRoutes() + s.AddCertificatesRoutes() } diff --git a/internal/sqlc/certificate.sql.go b/internal/sqlc/certificate.sql.go new file mode 100644 index 0000000..cbe5aa7 --- /dev/null +++ b/internal/sqlc/certificate.sql.go @@ -0,0 +1,241 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: certificate.sql + +package sqlc + +import ( + "context" +) + +const createCertificate = `-- name: CreateCertificate :one +INSERT INTO certificate ( + id, + name, + cert_type, + algorithm, + key_size, + pem_data, + metadata +) VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +` + +type CreateCertificateParams struct { + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + CertType string `db:"cert_type" json:"cert_type"` + Algorithm string `db:"algorithm" json:"algorithm"` + KeySize *int64 `db:"key_size" json:"key_size"` + PemData string `db:"pem_data" json:"pem_data"` + Metadata *string `db:"metadata" json:"metadata"` +} + +// CreateCertificate +// +// INSERT INTO certificate ( +// id, +// name, +// cert_type, +// algorithm, +// key_size, +// pem_data, +// metadata +// ) VALUES ( +// ?, ?, ?, ?, ?, ?, ? +// ) +// RETURNING id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +func (q *Queries) CreateCertificate(ctx context.Context, arg CreateCertificateParams) (Certificate, error) { + row := q.db.QueryRowContext(ctx, createCertificate, + arg.ID, + arg.Name, + arg.CertType, + arg.Algorithm, + arg.KeySize, + arg.PemData, + arg.Metadata, + ) + var i Certificate + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.CertType, + &i.Algorithm, + &i.KeySize, + &i.PemData, + &i.Metadata, + ) + return i, err +} + +const deleteCertificate = `-- name: DeleteCertificate :exec +DELETE FROM certificate +WHERE name = ? +` + +// DeleteCertificate +// +// DELETE FROM certificate +// WHERE name = ? +func (q *Queries) DeleteCertificate(ctx context.Context, name string) error { + _, err := q.db.ExecContext(ctx, deleteCertificate, name) + return err +} + +const getCertificate = `-- name: GetCertificate :one +SELECT id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +FROM certificate +WHERE name = ? +` + +// GetCertificate +// +// SELECT id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +// FROM certificate +// WHERE name = ? +func (q *Queries) GetCertificate(ctx context.Context, name string) (Certificate, error) { + row := q.db.QueryRowContext(ctx, getCertificate, name) + var i Certificate + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.CertType, + &i.Algorithm, + &i.KeySize, + &i.PemData, + &i.Metadata, + ) + return i, err +} + +const getCertificatesByType = `-- name: GetCertificatesByType :many +SELECT id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +FROM certificate +WHERE cert_type = ? +ORDER BY created_at DESC +` + +// GetCertificatesByType +// +// SELECT id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +// FROM certificate +// WHERE cert_type = ? +// ORDER BY created_at DESC +func (q *Queries) GetCertificatesByType(ctx context.Context, certType string) ([]Certificate, error) { + rows, err := q.db.QueryContext(ctx, getCertificatesByType, certType) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Certificate{} + for rows.Next() { + var i Certificate + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.CertType, + &i.Algorithm, + &i.KeySize, + &i.PemData, + &i.Metadata, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCertificates = `-- name: ListCertificates :many +SELECT id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +FROM certificate +ORDER BY created_at DESC +` + +// ListCertificates +// +// SELECT id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +// FROM certificate +// ORDER BY created_at DESC +func (q *Queries) ListCertificates(ctx context.Context) ([]Certificate, error) { + rows, err := q.db.QueryContext(ctx, listCertificates) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Certificate{} + for rows.Next() { + var i Certificate + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.CertType, + &i.Algorithm, + &i.KeySize, + &i.PemData, + &i.Metadata, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateCertificate = `-- name: UpdateCertificate :one +UPDATE certificate +SET + pem_data = ?, + metadata = ? +WHERE name = ? +RETURNING id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +` + +type UpdateCertificateParams struct { + PemData string `db:"pem_data" json:"pem_data"` + Metadata *string `db:"metadata" json:"metadata"` + Name string `db:"name" json:"name"` +} + +// UpdateCertificate +// +// UPDATE certificate +// SET +// pem_data = ?, +// metadata = ? +// WHERE name = ? +// RETURNING id, created_at, name, cert_type, algorithm, key_size, pem_data, metadata +func (q *Queries) UpdateCertificate(ctx context.Context, arg UpdateCertificateParams) (Certificate, error) { + row := q.db.QueryRowContext(ctx, updateCertificate, arg.PemData, arg.Metadata, arg.Name) + var i Certificate + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.CertType, + &i.Algorithm, + &i.KeySize, + &i.PemData, + &i.Metadata, + ) + return i, err +} diff --git a/internal/sqlc/models.go b/internal/sqlc/models.go index b3ea179..0585053 100644 --- a/internal/sqlc/models.go +++ b/internal/sqlc/models.go @@ -8,6 +8,17 @@ import ( "time" ) +type Certificate struct { + ID string `db:"id" json:"id"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + Name string `db:"name" json:"name"` + CertType string `db:"cert_type" json:"cert_type"` + Algorithm string `db:"algorithm" json:"algorithm"` + KeySize *int64 `db:"key_size" json:"key_size"` + PemData string `db:"pem_data" json:"pem_data"` + Metadata *string `db:"metadata" json:"metadata"` +} + type Log struct { ID string `db:"id" json:"id"` CreatedAt *time.Time `db:"created_at" json:"created_at"` diff --git a/internal/sqlite/secrets.sqlite b/internal/sqlite/secrets.sqlite index d05fa3f..dead76c 100644 Binary files a/internal/sqlite/secrets.sqlite and b/internal/sqlite/secrets.sqlite differ diff --git a/queries/certificate.sql b/queries/certificate.sql new file mode 100644 index 0000000..db319b1 --- /dev/null +++ b/queries/certificate.sql @@ -0,0 +1,41 @@ +-- name: CreateCertificate :one +INSERT INTO certificate ( + id, + name, + cert_type, + algorithm, + key_size, + pem_data, + metadata +) VALUES ( + ?, ?, ?, ?, ?, ?, ? +) +RETURNING *; + +-- name: GetCertificate :one +SELECT * +FROM certificate +WHERE name = ?; + +-- name: DeleteCertificate :exec +DELETE FROM certificate +WHERE name = ?; + +-- name: ListCertificates :many +SELECT * +FROM certificate +ORDER BY created_at DESC; + +-- name: UpdateCertificate :one +UPDATE certificate +SET + pem_data = ?, + metadata = ? +WHERE name = ? +RETURNING *; + +-- name: GetCertificatesByType :many +SELECT * +FROM certificate +WHERE cert_type = ? +ORDER BY created_at DESC; diff --git a/schema/6_certificate.sql b/schema/6_certificate.sql new file mode 100644 index 0000000..792483a --- /dev/null +++ b/schema/6_certificate.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS certificate ( + id TEXT PRIMARY KEY, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + name TEXT NOT NULL UNIQUE, + cert_type TEXT NOT NULL, -- 'private_key', 'public_key', 'certificate', 'ca_certificate' + algorithm TEXT NOT NULL, -- 'RSA', 'ECDSA', 'ED25519' + key_size INTEGER, -- for RSA: 2048, 3072, 4096; for ECDSA: 256, 384, 521 + pem_data TEXT NOT NULL, -- PEM encoded certificate/key data + metadata TEXT -- JSON metadata (issuer, subject, expiry, etc.) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE certificate; +-- +goose StatementEnd diff --git a/secretssdk/certificates.go b/secretssdk/certificates.go new file mode 100644 index 0000000..d2a1c42 --- /dev/null +++ b/secretssdk/certificates.go @@ -0,0 +1,361 @@ +package secretssdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Certificate struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + Name string `json:"name"` + CertType string `json:"cert_type"` + Algorithm string `json:"algorithm"` + KeySize *int64 `json:"key_size"` + PemData string `json:"pem_data"` + Metadata *string `json:"metadata"` +} + +type GenerateKeyPairRequest struct { + Name string `json:"name"` + Algorithm string `json:"algorithm"` // "RSA", "ECDSA", "ED25519" + KeySize *int64 `json:"key_size"` +} + +type GenerateKeyPairResponse struct { + PrivateKey Certificate `json:"private_key"` + PublicKey Certificate `json:"public_key"` +} + +type ImportCertificateRequest struct { + Name string `json:"name"` + CertType string `json:"cert_type"` + PemData string `json:"pem_data"` +} + +type GenerateCertificateRequest struct { + Name string `json:"name"` + PrivateKeyName string `json:"private_key_name"` + Subject Subject `json:"subject"` + ValidityDays int `json:"validity_days"` + IsCA bool `json:"is_ca"` + DNSNames []string `json:"dns_names,omitempty"` + EmailAddresses []string `json:"email_addresses,omitempty"` + SigningCertName *string `json:"signing_cert_name,omitempty"` +} + +type Subject struct { + CommonName string `json:"common_name"` + Organization string `json:"organization,omitempty"` + OrganizationalUnit string `json:"organizational_unit,omitempty"` + Country string `json:"country,omitempty"` + Province string `json:"province,omitempty"` + Locality string `json:"locality,omitempty"` +} + +type VerifyCertificateRequest struct { + CertificateName string `json:"certificate_name"` + CACertName *string `json:"ca_cert_name,omitempty"` +} + +type ExportCertificateResponse struct { + Name string `json:"name"` + PemData string `json:"pem_data"` +} + +// ListCertificates retrieves all certificates +func (c *Client) ListCertificates() ([]Certificate, error) { + endpoint := fmt.Sprintf("%s/api/certificates", c.BaseUrl) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.GetHttpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data []Certificate `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to list certificates") + } + + return result.Data, nil +} + +// GetCertificate retrieves a specific certificate by name +func (c *Client) GetCertificate(name string) (*Certificate, error) { + endpoint := fmt.Sprintf("%s/api/certificates/%s", c.BaseUrl, name) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.GetHttpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data Certificate `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to get certificate") + } + + return &result.Data, nil +} + +// GenerateKeyPair generates a new key pair (private and public key) +func (c *Client) GenerateKeyPair(req GenerateKeyPairRequest) (*GenerateKeyPairResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + endpoint := fmt.Sprintf("%s/api/certificates/generate-keypair", c.BaseUrl) + httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.GetHttpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data GenerateKeyPairResponse `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to generate key pair") + } + + return &result.Data, nil +} + +// ImportCertificate imports a certificate from PEM data +func (c *Client) ImportCertificate(req ImportCertificateRequest) (*Certificate, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + endpoint := fmt.Sprintf("%s/api/certificates/import", c.BaseUrl) + httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.GetHttpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data Certificate `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to import certificate") + } + + return &result.Data, nil +} + +// ExportCertificate exports a certificate as PEM data +func (c *Client) ExportCertificate(name string) (*ExportCertificateResponse, error) { + endpoint := fmt.Sprintf("%s/api/certificates/%s/export", c.BaseUrl, name) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.GetHttpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data ExportCertificateResponse `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to export certificate") + } + + return &result.Data, nil +} + +// GenerateCertificate generates a new X.509 certificate +func (c *Client) GenerateCertificate(req GenerateCertificateRequest) (*Certificate, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + endpoint := fmt.Sprintf("%s/api/certificates/generate-certificate", c.BaseUrl) + httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.GetHttpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data Certificate `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to generate certificate") + } + + return &result.Data, nil +} + +// VerifyCertificate verifies a certificate's validity and signature +func (c *Client) VerifyCertificate(req VerifyCertificateRequest) (map[string]interface{}, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + endpoint := fmt.Sprintf("%s/api/certificates/verify", c.BaseUrl) + httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.GetHttpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return nil, fmt.Errorf("failed to verify certificate") + } + + return result.Data, nil +} + +// DeleteCertificate deletes a certificate by name +func (c *Client) DeleteCertificate(name string) error { + endpoint := fmt.Sprintf("%s/api/certificates/%s", c.BaseUrl, name) + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.GetHttpClient().Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var result struct { + Success bool `json:"success"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + return fmt.Errorf("failed to delete certificate") + } + + return nil +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index b715178..eb0a44e 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/input.tsx","./src/components/modal.tsx","./src/components/spoiler.tsx","./src/components/table.tsx","./src/components/tabs.tsx","./src/components/toast.tsx","./src/hooks/useauth.ts","./src/hooks/userouter.ts","./src/hooks/usetoast.ts","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/panels/permissionspanel.tsx","./src/pages/panels/secretspanel.tsx","./src/pages/panels/tokenspanel.tsx","./src/pages/panels/userspanel.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/Button.tsx","./src/components/Input.tsx","./src/components/Modal.tsx","./src/components/Spoiler.tsx","./src/components/Table.tsx","./src/components/Tabs.tsx","./src/components/Toast.tsx","./src/hooks/useAuth.ts","./src/hooks/useRouter.ts","./src/hooks/useToast.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/panels/PermissionsPanel.tsx","./src/pages/panels/SecretsPanel.tsx","./src/pages/panels/TokensPanel.tsx","./src/pages/panels/UsersPanel.tsx"],"version":"5.9.3"} \ No newline at end of file